@ds-mo/ui 13.18.0 → 14.0.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 (218) hide show
  1. package/dist/.package-ready.json +1 -1
  2. package/dist/agent-patterns.json +2 -2
  3. package/dist/agent.json +137 -17
  4. package/dist/angular/ds-bar-action.d.ts +18 -0
  5. package/dist/angular/ds-bar-action.js +42 -0
  6. package/dist/angular/ds-table.d.ts +1 -4
  7. package/dist/angular/ds-table.js +2 -5
  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-avatar.js +1 -1
  19. package/dist/components/ds-banner.js +1 -1
  20. package/dist/components/ds-bar-action.d.ts +11 -0
  21. package/dist/components/ds-bar-action.js +2 -0
  22. package/dist/components/ds-bar-action.js.map +1 -0
  23. package/dist/components/ds-bar-nav.js +1 -1
  24. package/dist/components/ds-bar-title.js +1 -1
  25. package/dist/components/ds-bar-workflow.js +1 -1
  26. package/dist/components/ds-bar-workflow.js.map +1 -1
  27. package/dist/components/ds-button-filled.js +1 -1
  28. package/dist/components/ds-button-unfilled.js +1 -1
  29. package/dist/components/ds-card-chart.js +1 -1
  30. package/dist/components/ds-card-overview.js +1 -1
  31. package/dist/components/ds-card-setting.js +1 -1
  32. package/dist/components/ds-chip.js +1 -1
  33. package/dist/components/ds-code-block.js +1 -1
  34. package/dist/components/ds-conversation-list-item.js +1 -1
  35. package/dist/components/ds-conversation-list-item.js.map +1 -1
  36. package/dist/components/ds-conversation-list-section.js +1 -1
  37. package/dist/components/ds-empty-state.js +1 -1
  38. package/dist/components/ds-field.js +1 -1
  39. package/dist/components/ds-filter-menu.js +1 -1
  40. package/dist/components/ds-filter-menu.js.map +1 -1
  41. package/dist/components/ds-icon.js +1 -1
  42. package/dist/components/ds-input.js +1 -1
  43. package/dist/components/ds-markdown.js +1 -1
  44. package/dist/components/ds-menu.js +1 -1
  45. package/dist/components/ds-message-actions.js +1 -1
  46. package/dist/components/ds-message-composer.js +1 -1
  47. package/dist/components/ds-message-scroller.js +1 -1
  48. package/dist/components/ds-message.js +1 -1
  49. package/dist/components/ds-mobile-bar-nav.js +1 -1
  50. package/dist/components/ds-mobile-header.js +1 -1
  51. package/dist/components/ds-mobile-section-switcher.js +1 -1
  52. package/dist/components/ds-mobile-sheet-nav.js +1 -1
  53. package/dist/components/ds-modal.js +1 -1
  54. package/dist/components/ds-panel-nav.js +1 -1
  55. package/dist/components/ds-panel-tool-header.js +1 -1
  56. package/dist/components/ds-panel-tool-search.js +1 -1
  57. package/dist/components/ds-panel-tools.js +1 -1
  58. package/dist/components/ds-select.js +1 -1
  59. package/dist/components/ds-select.js.map +1 -1
  60. package/dist/components/ds-shell-app.js +1 -1
  61. package/dist/components/ds-shell-app.js.map +1 -1
  62. package/dist/components/ds-shell-page.js +1 -1
  63. package/dist/components/ds-shell-tools.js +1 -1
  64. package/dist/components/ds-swatch-picker.js +1 -1
  65. package/dist/components/ds-tab-group.js +1 -1
  66. package/dist/components/ds-table.js +1 -1
  67. package/dist/components/ds-table.js.map +1 -1
  68. package/dist/components/ds-tag.js +1 -1
  69. package/dist/components/ds-toast.js +1 -1
  70. package/dist/components/ds-typing-indicator.js +1 -1
  71. package/dist/components/{p-BxQlH4gl.js → p--z-DWRkg.js} +2 -2
  72. package/dist/components/p--z-DWRkg.js.map +1 -0
  73. package/dist/components/{p-DtvJTPds.js → p-1KtrhAC7.js} +2 -2
  74. package/dist/components/p-1KtrhAC7.js.map +1 -0
  75. package/dist/components/{p-BqFKxWVl.js → p-B7Hf86NF.js} +2 -2
  76. package/dist/components/{p-BCfgU8Fh.js → p-BDCrM8-1.js} +2 -2
  77. package/dist/components/p-BFfLvMZd.js +2 -0
  78. package/dist/components/p-BFfLvMZd.js.map +1 -0
  79. package/dist/components/p-BKsWDUnX.js +2 -0
  80. package/dist/components/p-BKsWDUnX.js.map +1 -0
  81. package/dist/components/{p-BOjGzd5N.js → p-BagXfn1G.js} +2 -2
  82. package/dist/components/{p-DeBto13S.js → p-Bf_D3crn.js} +2 -2
  83. package/dist/components/{p-CUQTEeLp.js → p-CElfUmMp.js} +2 -2
  84. package/dist/components/{p-BMq4DJWh.js → p-CIhETsHv.js} +2 -2
  85. package/dist/components/{p-5uI3i2vg.js → p-CQS5aWiC.js} +2 -2
  86. package/dist/components/{p-oyZjjoYV.js → p-CUeuemsF.js} +2 -2
  87. package/dist/components/{p-BQkZ8FIl.js → p-Cae9PFPE.js} +2 -2
  88. package/dist/components/{p-C_tqXl5P.js → p-Cih1XCy3.js} +2 -2
  89. package/dist/components/{p-N4m0evkj.js → p-CoTD4Mmb.js} +2 -2
  90. package/dist/components/{p-C7ywYAzN.js → p-D-kWb8cF.js} +2 -2
  91. package/dist/components/{p-BtY2nJW4.js → p-D0i_J2rj.js} +2 -2
  92. package/dist/components/{p-Qzh-wyTg.js → p-DLkNcCY0.js} +2 -2
  93. package/dist/components/p-DXHq_AfZ.js +2 -0
  94. package/dist/components/{p-CP2W8qxB.js.map → p-DXHq_AfZ.js.map} +1 -1
  95. package/dist/components/{p-Xr9OO-Ao.js → p-DXgSSxyB.js} +2 -2
  96. package/dist/components/{p-CswBI711.js → p-DY_sA9Jz.js} +2 -2
  97. package/dist/components/{p-BDsAON4I.js → p-DatlGTJp.js} +2 -2
  98. package/dist/components/{p-NzOcZg8r.js → p-DcPVSNDr.js} +2 -2
  99. package/dist/components/{p-CB8iBV_M.js → p-DofS_hVZ.js} +2 -2
  100. package/dist/components/p-DofS_hVZ.js.map +1 -0
  101. package/dist/components/{p-smzwm_EW.js → p-S9IjLCGj.js} +2 -2
  102. package/dist/components/{p-C8keZ4k6.js → p-ZtJM0cZm.js} +2 -2
  103. package/dist/components/{p-NuRg_IoA.js → p-nMkoXLC0.js} +2 -2
  104. package/dist/docs/components.json +170 -30
  105. package/dist/mcp-data/registry/agent-activity.json +2 -2
  106. package/dist/mcp-data/registry/agent-questionnaire.json +2 -2
  107. package/dist/mcp-data/registry/agent-response.json +2 -2
  108. package/dist/mcp-data/registry/agent-source-list.json +2 -2
  109. package/dist/mcp-data/registry/agent-tool-call.json +2 -2
  110. package/dist/mcp-data/registry/attachment-list.json +2 -2
  111. package/dist/mcp-data/registry/avatar.json +2 -2
  112. package/dist/mcp-data/registry/badge.json +2 -2
  113. package/dist/mcp-data/registry/banner.json +2 -2
  114. package/dist/mcp-data/registry/bar-action.json +237 -0
  115. package/dist/mcp-data/registry/bar-nav.json +2 -2
  116. package/dist/mcp-data/registry/bar-title.json +2 -2
  117. package/dist/mcp-data/registry/bar-workflow.json +2 -2
  118. package/dist/mcp-data/registry/breadcrumb.json +2 -2
  119. package/dist/mcp-data/registry/button-filled.json +2 -2
  120. package/dist/mcp-data/registry/button-unfilled.json +8 -8
  121. package/dist/mcp-data/registry/card-chart.json +2 -2
  122. package/dist/mcp-data/registry/card-overview.json +3 -3
  123. package/dist/mcp-data/registry/card-setting.json +2 -2
  124. package/dist/mcp-data/registry/chart-legend.json +2 -2
  125. package/dist/mcp-data/registry/chart.json +2 -2
  126. package/dist/mcp-data/registry/checkbox.json +2 -2
  127. package/dist/mcp-data/registry/chip.json +2 -2
  128. package/dist/mcp-data/registry/code-block.json +2 -2
  129. package/dist/mcp-data/registry/conversation-list-item.json +2 -2
  130. package/dist/mcp-data/registry/conversation-list-section.json +2 -2
  131. package/dist/mcp-data/registry/conversation-list.json +2 -2
  132. package/dist/mcp-data/registry/divider.json +2 -2
  133. package/dist/mcp-data/registry/empty-state.json +2 -2
  134. package/dist/mcp-data/registry/field.json +2 -2
  135. package/dist/mcp-data/registry/filter-menu.json +5 -4
  136. package/dist/mcp-data/registry/icon.json +3 -3
  137. package/dist/mcp-data/registry/input.json +2 -2
  138. package/dist/mcp-data/registry/loader.json +2 -2
  139. package/dist/mcp-data/registry/markdown.json +2 -2
  140. package/dist/mcp-data/registry/menu.json +3 -3
  141. package/dist/mcp-data/registry/message-actions.json +2 -2
  142. package/dist/mcp-data/registry/message-bubble.json +2 -2
  143. package/dist/mcp-data/registry/message-composer.json +2 -2
  144. package/dist/mcp-data/registry/message-scroller.json +2 -2
  145. package/dist/mcp-data/registry/message.json +2 -2
  146. package/dist/mcp-data/registry/mobile-bar-nav.json +2 -2
  147. package/dist/mcp-data/registry/mobile-header.json +2 -2
  148. package/dist/mcp-data/registry/mobile-section-switcher.json +2 -2
  149. package/dist/mcp-data/registry/mobile-sheet-nav.json +2 -2
  150. package/dist/mcp-data/registry/modal.json +2 -2
  151. package/dist/mcp-data/registry/panel-nav.json +2 -2
  152. package/dist/mcp-data/registry/panel-sub-nav.json +2 -2
  153. package/dist/mcp-data/registry/panel-tool-header.json +3 -3
  154. package/dist/mcp-data/registry/panel-tool-search.json +2 -2
  155. package/dist/mcp-data/registry/panel-tools.json +2 -2
  156. package/dist/mcp-data/registry/radio.json +2 -2
  157. package/dist/mcp-data/registry/registry.json +141 -18
  158. package/dist/mcp-data/registry/scroll-overlay.json +3 -3
  159. package/dist/mcp-data/registry/select.json +4 -3
  160. package/dist/mcp-data/registry/shell-app.json +3 -3
  161. package/dist/mcp-data/registry/shell-page.json +2 -2
  162. package/dist/mcp-data/registry/shell-tools.json +3 -3
  163. package/dist/mcp-data/registry/skeleton.json +2 -2
  164. package/dist/mcp-data/registry/slider.json +2 -2
  165. package/dist/mcp-data/registry/swatch-picker.json +2 -2
  166. package/dist/mcp-data/registry/switch.json +2 -2
  167. package/dist/mcp-data/registry/tab-group.json +2 -2
  168. package/dist/mcp-data/registry/table.json +16 -29
  169. package/dist/mcp-data/registry/tag.json +2 -2
  170. package/dist/mcp-data/registry/text.json +2 -2
  171. package/dist/mcp-data/registry/toast.json +2 -2
  172. package/dist/mcp-data/registry/tooltip-chart.json +2 -2
  173. package/dist/mcp-data/registry/tooltip.json +2 -2
  174. package/dist/mcp-data/registry/typing-indicator.json +2 -2
  175. package/dist/react/components.d.ts +1 -0
  176. package/dist/react/components.js +1 -0
  177. package/dist/react/ds-bar-action.d.ts +12 -0
  178. package/dist/react/ds-bar-action.js +12 -0
  179. package/dist/react/ds-table.d.ts +1 -2
  180. package/dist/react/ds-table.js +0 -1
  181. package/dist/styles/table.css +16 -4
  182. package/dist/types/components/BarAction/BarAction.d.ts +25 -0
  183. package/dist/types/components/ButtonUnfilled/ButtonUnfilled.d.ts +2 -2
  184. package/dist/types/components/Table/Table.d.ts +2 -3
  185. package/dist/types/components/Table/index.d.ts +1 -1
  186. package/dist/types/components/Table/table-model.d.ts +1 -2
  187. package/dist/types/components/Table/table-types.d.ts +1 -4
  188. package/dist/types/components.d.ts +84 -8
  189. package/package.json +5 -5
  190. package/dist/components/p-BxQlH4gl.js.map +0 -1
  191. package/dist/components/p-C8U5BsJf.js +0 -2
  192. package/dist/components/p-C8U5BsJf.js.map +0 -1
  193. package/dist/components/p-CB8iBV_M.js.map +0 -1
  194. package/dist/components/p-CP2W8qxB.js +0 -2
  195. package/dist/components/p-Csq6XDgm.js +0 -2
  196. package/dist/components/p-Csq6XDgm.js.map +0 -1
  197. package/dist/components/p-DtvJTPds.js.map +0 -1
  198. /package/dist/components/{p-BqFKxWVl.js.map → p-B7Hf86NF.js.map} +0 -0
  199. /package/dist/components/{p-BCfgU8Fh.js.map → p-BDCrM8-1.js.map} +0 -0
  200. /package/dist/components/{p-BOjGzd5N.js.map → p-BagXfn1G.js.map} +0 -0
  201. /package/dist/components/{p-DeBto13S.js.map → p-Bf_D3crn.js.map} +0 -0
  202. /package/dist/components/{p-CUQTEeLp.js.map → p-CElfUmMp.js.map} +0 -0
  203. /package/dist/components/{p-BMq4DJWh.js.map → p-CIhETsHv.js.map} +0 -0
  204. /package/dist/components/{p-5uI3i2vg.js.map → p-CQS5aWiC.js.map} +0 -0
  205. /package/dist/components/{p-oyZjjoYV.js.map → p-CUeuemsF.js.map} +0 -0
  206. /package/dist/components/{p-BQkZ8FIl.js.map → p-Cae9PFPE.js.map} +0 -0
  207. /package/dist/components/{p-C_tqXl5P.js.map → p-Cih1XCy3.js.map} +0 -0
  208. /package/dist/components/{p-N4m0evkj.js.map → p-CoTD4Mmb.js.map} +0 -0
  209. /package/dist/components/{p-C7ywYAzN.js.map → p-D-kWb8cF.js.map} +0 -0
  210. /package/dist/components/{p-BtY2nJW4.js.map → p-D0i_J2rj.js.map} +0 -0
  211. /package/dist/components/{p-Qzh-wyTg.js.map → p-DLkNcCY0.js.map} +0 -0
  212. /package/dist/components/{p-Xr9OO-Ao.js.map → p-DXgSSxyB.js.map} +0 -0
  213. /package/dist/components/{p-CswBI711.js.map → p-DY_sA9Jz.js.map} +0 -0
  214. /package/dist/components/{p-BDsAON4I.js.map → p-DatlGTJp.js.map} +0 -0
  215. /package/dist/components/{p-NzOcZg8r.js.map → p-DcPVSNDr.js.map} +0 -0
  216. /package/dist/components/{p-smzwm_EW.js.map → p-S9IjLCGj.js.map} +0 -0
  217. /package/dist/components/{p-C8keZ4k6.js.map → p-ZtJM0cZm.js.map} +0 -0
  218. /package/dist/components/{p-NuRg_IoA.js.map → p-nMkoXLC0.js.map} +0 -0
@@ -528,8 +528,8 @@
528
528
  },
529
529
  "peerDependencies": {
530
530
  "required": [
531
- "@ds-mo/tokens ^6.2.1",
532
- "@ds-mo/icons ^6.6.0"
531
+ "@ds-mo/tokens ^6.2.2",
532
+ "@ds-mo/icons ^7.0.0"
533
533
  ],
534
534
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
535
535
  }
@@ -53,7 +53,7 @@
53
53
  ],
54
54
  "accessibility": [
55
55
  "Provide an explicit accessible name for every icon-only action.",
56
- "Set hasMenu when the button triggers a Menu; it implies aria-haspopup of menu. Pass controls with the Menu id and expanded from the same open state given to Menu. Expanded popup triggers hold the pressed visual while open; expanded disclosure controls without popup semantics retain the normal resting appearance.",
56
+ "Set hasMenu when the button triggers a Menu; it implies aria-haspopup of menu. Pass controls with the Menu id and expanded from the same open state given to Menu. Expanded popup triggers hold only the pressed wash while open, without changing their resting foreground; expanded disclosure controls without popup semantics retain the normal resting appearance.",
57
57
  "Give an icon-only menu button an accessible name that states the role, such as More options, because no visible label or chevron carries it.",
58
58
  "Use haspopup directly for a non-menu popup such as a listbox or dialog; those cases get no chevron.",
59
59
  "Use pressed only for a genuine toggle button; it supplies aria-pressed and dsChange intent. Use isActive only for owner-controlled visual emphasis inside a composite whose selection semantics live on the owner.",
@@ -76,7 +76,7 @@
76
76
  "Icon and icon-label loading replace the icon in place. Label-only loading centers the loader while preserving the button's measured width.",
77
77
  "hasMenu covers two distinct menu-button shapes and the variant selects between them. With label or icon-label the action has a menu, and a trailing chevron carries the affordance. With icon the button is a menu, meaning the overflow or more-options control, and no chevron is added.",
78
78
  "Because the icon-only shape has no chevron, its glyph must convey the menu on its own; use Ellipses. Never pair hasMenu with an icon-only glyph that does not read as a menu.",
79
- "An open popup is a transient pressed state rather than a selected one. While expanded is true and haspopup or hasMenu identifies a popup, the pressed wash is held for the popup's complete rendered lifecycle and survives hover, including when chrome opts out of selected fills with activeFill set to false. Expanded disclosure buttons without popup semantics remain visually neutral."
79
+ "An open popup is a transient pressed state rather than a selected one. While expanded is true and haspopup or hasMenu identifies a popup, the pressed wash is held for the popup's complete rendered lifecycle and survives hover without promoting foreground or selected fill, including when chrome opts out of selected fills with activeFill set to false. Expanded disclosure buttons without popup semantics remain visually neutral."
80
80
  ],
81
81
  "responsiveBehavior": [
82
82
  "The consuming layout decides when an icon-label action becomes icon-only and must provide an explicit accessible name after changing variants.",
@@ -151,7 +151,7 @@
151
151
  "attribute": "expanded",
152
152
  "required": false,
153
153
  "mutable": false,
154
- "description": "Controlled disclosure or popup state forwarded to `aria-expanded`.\nPopup triggers hold a pressed visual while open; ordinary disclosures\nretain the normal resting appearance."
154
+ "description": "Controlled disclosure or popup state forwarded to `aria-expanded`.\nPopup triggers hold only the pressed wash while open; their resting\nforeground is unchanged. Ordinary disclosures remain visually neutral."
155
155
  },
156
156
  "focusTabIndex": {
157
157
  "type": "number",
@@ -385,7 +385,7 @@
385
385
  "attribute": "expanded",
386
386
  "required": false,
387
387
  "mutable": false,
388
- "description": "Controlled disclosure or popup state forwarded to `aria-expanded`.\nPopup triggers hold a pressed visual while open; ordinary disclosures\nretain the normal resting appearance."
388
+ "description": "Controlled disclosure or popup state forwarded to `aria-expanded`.\nPopup triggers hold only the pressed wash while open; their resting\nforeground is unchanged. Ordinary disclosures remain visually neutral."
389
389
  },
390
390
  "focusTabIndex": {
391
391
  "type": "number",
@@ -591,8 +591,8 @@
591
591
  },
592
592
  "peerDependencies": {
593
593
  "required": [
594
- "@ds-mo/tokens ^6.2.1",
595
- "@ds-mo/icons ^6.6.0"
594
+ "@ds-mo/tokens ^6.2.2",
595
+ "@ds-mo/icons ^7.0.0"
596
596
  ],
597
597
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
598
598
  }
@@ -612,12 +612,12 @@
612
612
  "files": [
613
613
  {
614
614
  "path": "src/wc/components/ButtonUnfilled/ButtonUnfilled.css",
615
- "content": "@import '../../utils/button-base.css';\n\n:host {\n --ds-button-unfilled-fg: var(--color-foreground-secondary);\n --ds-button-unfilled-fg-active: var(--color-foreground-primary);\n --ds-button-unfilled-dot-ring: var(--color-background-secondary);\n --ds-button-unfilled-border: var(--color-border-secondary);\n}\n\n.button-unfilled {\n background: transparent;\n color: var(--ds-button-unfilled-fg);\n /* Hover/press/selected fill: `.ds-interaction-fill` (+ `--selected` / `--on-*`).\n Active and popup-open foreground changes resolve atomically with the fill. */\n}\n\n.button-unfilled--active {\n color: var(--ds-button-unfilled-fg-active);\n}\n\n/*\n * An open popup is a transient pressed state, not a selected state. Keep the\n * pressed wash visible for the popup's complete rendered lifecycle even when\n * shell chrome opts out of selected fills with `activeFill=false`.\n */\n/* Inset stroke via interaction-fill ::after (above selected / hover washes). */\n.button-unfilled--bordered {\n --ds-interaction-border-color: var(--ds-button-unfilled-border);\n}\n\n/* Inactive: `.ds-control-inactive` (50% opacity). */\n\n/* Foreground / chrome tokens per surface — interaction tokens come from util `--on-*`. */\n.button-unfilled--background-medium {\n --ds-button-unfilled-fg: var(--color-foreground-on-medium-background-secondary);\n --ds-button-unfilled-fg-active: var(--color-foreground-on-medium-background-primary);\n --ds-button-unfilled-dot-ring: var(--color-background-medium-neutral);\n --ds-button-unfilled-border: var(--color-border-on-medium-background-secondary);\n}\n\n.button-unfilled--background-bold {\n --ds-button-unfilled-fg: var(--color-foreground-on-bold-background-secondary);\n --ds-button-unfilled-fg-active: var(--color-foreground-on-bold-background-primary);\n --ds-button-unfilled-dot-ring: var(--color-background-bold-neutral);\n --ds-button-unfilled-border: var(--color-border-on-bold-background-secondary);\n}\n\n.button-unfilled--background-strong {\n --ds-button-unfilled-fg: var(--color-foreground-on-strong-background-secondary);\n --ds-button-unfilled-fg-active: var(--color-foreground-on-strong-background-primary);\n --ds-button-unfilled-dot-ring: var(--color-background-strong-neutral);\n --ds-button-unfilled-border: var(--color-border-on-strong-background-secondary);\n}\n\n.button-unfilled--background-translucent {\n --ds-button-unfilled-fg: var(--color-translucent-foreground-secondary);\n --ds-button-unfilled-fg-active: var(--color-translucent-foreground-primary);\n --ds-button-unfilled-dot-ring: var(--color-translucent-translucent);\n --ds-button-unfilled-border: var(--color-translucent-border-secondary);\n}\n\n.button-unfilled--background-inverted {\n --ds-button-unfilled-fg: var(--color-inverted-foreground-secondary);\n --ds-button-unfilled-fg-active: var(--color-inverted-foreground-primary);\n --ds-button-unfilled-dot-ring: var(--color-inverted-background);\n --ds-button-unfilled-border: var(--color-inverted-border-secondary);\n}\n\n.button-unfilled--background-media {\n --ds-button-unfilled-fg: var(--color-media-foreground-secondary);\n --ds-button-unfilled-fg-active: var(--color-media-foreground-primary);\n --ds-button-unfilled-dot-ring: var(--color-media-background);\n --ds-button-unfilled-border: var(--color-media-border-secondary);\n}\n\n.button-unfilled--on-always-dark {\n --ds-button-unfilled-fg: var(--color-always-dark-foreground-secondary);\n --ds-button-unfilled-fg-active: var(--color-always-dark-foreground-primary);\n --ds-button-unfilled-dot-ring: var(--color-always-dark-background);\n --ds-button-unfilled-border: var(--color-always-dark-border-secondary);\n}\n\n/*\n * After surface ring defaults: selected fill remaps the knock-out to the active\n * wash so the badge halo matches the selected background.\n */\n.button-unfilled.ds-interaction-fill--selected {\n --ds-button-unfilled-dot-ring: var(--ds-interaction-active);\n}\n",
615
+ "content": "@import '../../utils/button-base.css';\n\n:host {\n --ds-button-unfilled-fg: var(--color-foreground-secondary);\n --ds-button-unfilled-fg-active: var(--color-foreground-primary);\n --ds-button-unfilled-dot-ring: var(--color-background-secondary);\n --ds-button-unfilled-border: var(--color-border-secondary);\n}\n\n.button-unfilled {\n background: transparent;\n color: var(--ds-button-unfilled-fg);\n /* Hover/press/selected fill: `.ds-interaction-fill` (+ `--selected` / `--on-*`).\n Active foreground changes remain independent from popup-open pressed paint. */\n}\n\n.button-unfilled--active {\n color: var(--ds-button-unfilled-fg-active);\n}\n\n/*\n * An open popup is a transient pressed state, not a selected state. Keep the\n * pressed wash visible for the popup's complete rendered lifecycle without\n * promoting the resting foreground or selected fill.\n */\n/* Inset stroke via interaction-fill ::after (above selected / hover washes). */\n.button-unfilled--bordered {\n --ds-interaction-border-color: var(--ds-button-unfilled-border);\n}\n\n/* Inactive: `.ds-control-inactive` (50% opacity). */\n\n/* Foreground / chrome tokens per surface — interaction tokens come from util `--on-*`. */\n.button-unfilled--background-medium {\n --ds-button-unfilled-fg: var(--color-foreground-on-medium-background-secondary);\n --ds-button-unfilled-fg-active: var(--color-foreground-on-medium-background-primary);\n --ds-button-unfilled-dot-ring: var(--color-background-medium-neutral);\n --ds-button-unfilled-border: var(--color-border-on-medium-background-secondary);\n}\n\n.button-unfilled--background-bold {\n --ds-button-unfilled-fg: var(--color-foreground-on-bold-background-secondary);\n --ds-button-unfilled-fg-active: var(--color-foreground-on-bold-background-primary);\n --ds-button-unfilled-dot-ring: var(--color-background-bold-neutral);\n --ds-button-unfilled-border: var(--color-border-on-bold-background-secondary);\n}\n\n.button-unfilled--background-strong {\n --ds-button-unfilled-fg: var(--color-foreground-on-strong-background-secondary);\n --ds-button-unfilled-fg-active: var(--color-foreground-on-strong-background-primary);\n --ds-button-unfilled-dot-ring: var(--color-background-strong-neutral);\n --ds-button-unfilled-border: var(--color-border-on-strong-background-secondary);\n}\n\n.button-unfilled--background-translucent {\n --ds-button-unfilled-fg: var(--color-translucent-foreground-secondary);\n --ds-button-unfilled-fg-active: var(--color-translucent-foreground-primary);\n --ds-button-unfilled-dot-ring: var(--color-translucent-translucent);\n --ds-button-unfilled-border: var(--color-translucent-border-secondary);\n}\n\n.button-unfilled--background-inverted {\n --ds-button-unfilled-fg: var(--color-inverted-foreground-secondary);\n --ds-button-unfilled-fg-active: var(--color-inverted-foreground-primary);\n --ds-button-unfilled-dot-ring: var(--color-inverted-background);\n --ds-button-unfilled-border: var(--color-inverted-border-secondary);\n}\n\n.button-unfilled--background-media {\n --ds-button-unfilled-fg: var(--color-media-foreground-secondary);\n --ds-button-unfilled-fg-active: var(--color-media-foreground-primary);\n --ds-button-unfilled-dot-ring: var(--color-media-background);\n --ds-button-unfilled-border: var(--color-media-border-secondary);\n}\n\n.button-unfilled--on-always-dark {\n --ds-button-unfilled-fg: var(--color-always-dark-foreground-secondary);\n --ds-button-unfilled-fg-active: var(--color-always-dark-foreground-primary);\n --ds-button-unfilled-dot-ring: var(--color-always-dark-background);\n --ds-button-unfilled-border: var(--color-always-dark-border-secondary);\n}\n\n/*\n * After surface ring defaults: selected fill remaps the knock-out to the active\n * wash so the badge halo matches the selected background.\n */\n.button-unfilled.ds-interaction-fill--selected {\n --ds-button-unfilled-dot-ring: var(--ds-interaction-active);\n}\n",
616
616
  "type": "registry:ui"
617
617
  },
618
618
  {
619
619
  "path": "src/wc/components/ButtonUnfilled/ButtonUnfilled.tsx",
620
- "content": "import { Component, Element, Event, EventEmitter, h, Host, Method, Prop } from '@stencil/core';\nimport { controlWidthClass } from '../../utils';\nimport { beginElevatedControlPress } from '../../utils/control-press';\nimport { renderButtonContent } from '../../utils/button-render';\nimport type { ControlInsetDepth } from '../../utils/control-text';\nimport type {\n ButtonPopup,\n ButtonSize,\n ButtonVariant,\n ButtonWidth,\n} from '../../utils/button-types';\n\nexport type ButtonUnfilledBackground =\n | 'faint'\n | 'medium'\n | 'bold'\n | 'strong'\n | 'translucent'\n | 'inverted'\n | 'media'\n | 'always-dark';\n\nexport type ButtonUnfilledVariant = ButtonVariant;\nexport type ButtonUnfilledSize = ButtonSize;\nexport type ButtonUnfilledWidth = ButtonWidth;\nexport type ButtonUnfilledPopup = ButtonPopup;\n\n@Component({\n tag: 'ds-button-unfilled',\n styleUrl: 'ButtonUnfilled.css',\n scoped: true,\n})\nexport class ButtonUnfilled {\n @Element() el!: HTMLElement;\n\n /**\n * Content layout. Default is label-only; pass `icon` for icon-only chrome\n * (nav / tool rails) or `icon-label` for leading icon + text.\n */\n @Prop() variant: ButtonUnfilledVariant = 'label';\n\n /** Control density (height, padding, icon, type). */\n @Prop() size: ButtonUnfilledSize = 'md';\n\n /** Use reduced outer geometry when nested inside a control of the same size. */\n @Prop() isInset: boolean = false;\n\n /** Single removes 4px overall; double removes 8px overall (xs stays single). */\n @Prop() insetDepth: ControlInsetDepth = 'single';\n\n /** Width fit — hug content (default) or fill the parent. */\n @Prop() width: ButtonUnfilledWidth = 'hug';\n\n /** Visible text for `label` / `icon-label` variants. */\n @Prop() label: string = '';\n\n /** Icon name passed to <ds-icon> for `icon` / `icon-label` variants. */\n @Prop() icon: string = '';\n\n /**\n * Owner-controlled visual emphasis inside a composite. This does not add\n * toggle semantics and does not emit dsChange.\n */\n @Prop() isActive: boolean = false;\n\n /**\n * When active, render the selected interaction fill.\n * Default `true` for general UI. Shell chrome (nav / tool rails) should pass\n * `false` so selection is foreground-only (primary color, no fill).\n */\n @Prop() activeFill: boolean = true;\n\n /** Show a 1px secondary inset border. Default on; shell chrome can pass `false`. */\n @Prop() hasBorder: boolean = true;\n\n /** Use the half-radius treatment instead of the default control radius. */\n @Prop() rounded: boolean = false;\n\n /**\n * Scale down during a physical pointer press.\n * Disable when an owning composite requires fixed child or background geometry.\n */\n @Prop() pressScale: boolean = true;\n\n /** Show a notification dot at the top-right of the icon zone (icon variant only). */\n @Prop() dot: boolean = false;\n\n /** Disables interaction. */\n @Prop() isInactive: boolean = false;\n\n /** Shows an inline loader and prevents interaction without applying inactive opacity. */\n @Prop() isLoading: boolean = false;\n\n /** Native button type. */\n @Prop() type: 'button' | 'submit' | 'reset' = 'button';\n\n /** Actual parent surface context. Omit on primary and secondary surfaces. */\n @Prop() background: ButtonUnfilledBackground | undefined;\n\n /** Accessible name override. Required for icon-only buttons. */\n @Prop({ attribute: 'aria-label' }) ariaLabel: string | null = null;\n @Prop() controls: string | undefined;\n /**\n * Controlled disclosure or popup state forwarded to `aria-expanded`.\n * Popup triggers hold a pressed visual while open; ordinary disclosures\n * retain the normal resting appearance.\n */\n @Prop() expanded: boolean | undefined;\n @Prop() haspopup: ButtonUnfilledPopup | undefined;\n /**\n * Controlled state for a genuine toggle button. Adds aria-pressed, promotes\n * active styling, and makes activation emit dsChange with the requested state.\n */\n @Prop() pressed: boolean | undefined;\n\n /**\n * Mark the button as a Menu trigger. Implies `aria-haspopup=\"menu\"` and covers\n * both menu-button shapes:\n *\n * - `label` / `icon-label` — the action *has* a menu; a trailing chevron carries\n * the affordance.\n * - `icon` — the button *is* a menu, i.e. the overflow / more-options control.\n * No chevron is added, so the glyph must convey it on its own; use `Ellipses`.\n *\n * Use `haspopup` directly for non-menu popups.\n */\n @Prop() hasMenu: boolean = false;\n\n /**\n * Native `tabindex` for roving keyboard groups in shell chrome.\n * Omit for the default button tab stop (`0`).\n */\n @Prop({ attribute: 'tab-index' }) focusTabIndex?: number;\n\n @Event() dsClick!: EventEmitter<MouseEvent>;\n @Event() dsChange!: EventEmitter<boolean>;\n\n private buttonEl: HTMLButtonElement | null = null;\n\n @Method()\n async setFocus() {\n this.buttonEl?.focus();\n }\n\n private handleClick = (event: MouseEvent) => {\n if (this.isInactive || this.isLoading) {\n event.preventDefault();\n event.stopPropagation();\n return;\n }\n this.dsClick.emit(event);\n if (this.pressed !== undefined) this.dsChange.emit(!this.pressed);\n };\n\n private get showDot(): boolean {\n return this.variant === 'icon' && this.dot && !this.isLoading;\n }\n\n private get resolvedHaspopup(): ButtonUnfilledPopup | undefined {\n return this.haspopup ?? (this.hasMenu ? 'menu' : undefined);\n }\n\n private get doubleInset(): boolean {\n return this.isInset && this.insetDepth === 'double' && this.size !== 'xs';\n }\n\n /** Only popup triggers hold a pressed visual while expanded. */\n private get expandedPopup(): boolean {\n return this.expanded === true && this.resolvedHaspopup !== undefined && !this.isInactive;\n }\n\n private get visuallyActive(): boolean {\n return this.isActive || this.pressed === true || this.expandedPopup;\n }\n\n /** Knock-out ring: selected fill → active wash; otherwise surface token. */\n private get dotRing(): string {\n if ((this.isActive || this.pressed === true) && this.activeFill) {\n return 'var(--ds-interaction-active)';\n }\n return 'var(--ds-button-unfilled-dot-ring)';\n }\n\n private get accessibleName(): string | undefined {\n if (this.ariaLabel) return this.ariaLabel;\n if (this.isLoading && this.variant === 'label' && this.label) return this.label;\n return undefined;\n }\n\n render() {\n const bg = this.background;\n const cls: Record<string, boolean> = {\n 'button-unfilled': true,\n 'ds-button': true,\n 'ds-focus-ring-inset': true,\n 'ds-control-press-scale': this.pressScale,\n 'ds-interaction-fill': true,\n 'ds-interaction-fill--selected':\n (this.isActive || this.pressed === true) &&\n this.activeFill &&\n !this.isInactive,\n 'ds-interaction-fill--on-faint': bg === 'faint',\n 'ds-interaction-fill--on-medium': bg === 'medium',\n 'ds-interaction-fill--on-bold': bg === 'bold',\n 'ds-interaction-fill--on-strong': bg === 'strong',\n 'ds-interaction-fill--on-translucent': bg === 'translucent',\n 'ds-interaction-fill--on-inverted': bg === 'inverted',\n 'ds-interaction-fill--on-media': bg === 'media',\n 'ds-interaction-fill--on-always-dark': bg === 'always-dark',\n 'button-unfilled--active': this.visuallyActive,\n 'button-unfilled--expanded': this.expandedPopup,\n 'ds-button--expanded': this.expandedPopup,\n 'button-unfilled--bordered': this.hasBorder,\n 'ds-button--bordered': this.hasBorder,\n 'button-unfilled--rounded': this.rounded,\n 'ds-button--rounded': this.rounded,\n 'ds-control-inactive': this.isInactive,\n 'ds-control--lg': this.size === 'lg',\n 'ds-control--md': this.size === 'md',\n 'ds-control--sm': this.size === 'sm',\n 'ds-control--xs': this.size === 'xs',\n 'ds-control--inset': this.isInset && !this.doubleInset,\n 'ds-control--inset-double': this.doubleInset,\n 'ds-control-frame': true,\n 'button-unfilled--icon': this.variant === 'icon',\n 'ds-button--icon': this.variant === 'icon',\n 'button-unfilled--label': this.variant === 'label',\n 'button-unfilled--icon-label': this.variant === 'icon-label',\n 'button-unfilled--background-faint': bg === 'faint',\n 'button-unfilled--background-medium': bg === 'medium',\n 'button-unfilled--background-bold': bg === 'bold',\n 'button-unfilled--background-strong': bg === 'strong',\n 'button-unfilled--background-translucent': bg === 'translucent',\n 'button-unfilled--background-inverted': bg === 'inverted',\n 'button-unfilled--background-media': bg === 'media',\n 'button-unfilled--on-always-dark': bg === 'always-dark',\n };\n\n return (\n <Host\n class={{\n 'button-unfilled-host': true,\n 'button-unfilled-host--icon': this.variant === 'icon',\n 'ds-button-host--icon': this.variant === 'icon',\n 'ds-control--lg': this.size === 'lg',\n 'ds-control--md': this.size === 'md',\n 'ds-control--sm': this.size === 'sm',\n 'ds-control--xs': this.size === 'xs',\n 'ds-control--inset': this.isInset && !this.doubleInset,\n 'ds-control--inset-double': this.doubleInset,\n ...controlWidthClass(this.width),\n }}\n tabIndex={-1}\n >\n <button\n ref={el => {\n this.buttonEl = el ?? null;\n }}\n type={this.type}\n class={cls}\n disabled={this.isInactive}\n tabIndex={this.focusTabIndex ?? 0}\n aria-label={this.accessibleName}\n aria-busy={this.isLoading ? 'true' : undefined}\n aria-disabled={this.isLoading ? 'true' : undefined}\n aria-controls={this.controls}\n aria-expanded={this.expanded === undefined ? undefined : String(this.expanded)}\n aria-haspopup={this.resolvedHaspopup}\n aria-pressed={this.pressed === undefined ? undefined : String(this.pressed)}\n onPointerDown={event =>\n beginElevatedControlPress(\n event,\n this.pressScale && !this.isInactive && !this.isLoading,\n )\n }\n onClick={this.handleClick}\n >\n {renderButtonContent({\n namespace: 'button-unfilled',\n variant: this.variant,\n size: this.size,\n label: this.label,\n icon: this.icon,\n hasMenu: this.hasMenu,\n isLoading: this.isLoading,\n dot: {\n visible: this.showDot,\n background: this.dotRing,\n },\n })}\n </button>\n </Host>\n );\n }\n}\n",
620
+ "content": "import { Component, Element, Event, EventEmitter, h, Host, Method, Prop } from '@stencil/core';\nimport { controlWidthClass } from '../../utils';\nimport { beginElevatedControlPress } from '../../utils/control-press';\nimport { renderButtonContent } from '../../utils/button-render';\nimport type { ControlInsetDepth } from '../../utils/control-text';\nimport type {\n ButtonPopup,\n ButtonSize,\n ButtonVariant,\n ButtonWidth,\n} from '../../utils/button-types';\n\nexport type ButtonUnfilledBackground =\n | 'faint'\n | 'medium'\n | 'bold'\n | 'strong'\n | 'translucent'\n | 'inverted'\n | 'media'\n | 'always-dark';\n\nexport type ButtonUnfilledVariant = ButtonVariant;\nexport type ButtonUnfilledSize = ButtonSize;\nexport type ButtonUnfilledWidth = ButtonWidth;\nexport type ButtonUnfilledPopup = ButtonPopup;\n\n@Component({\n tag: 'ds-button-unfilled',\n styleUrl: 'ButtonUnfilled.css',\n scoped: true,\n})\nexport class ButtonUnfilled {\n @Element() el!: HTMLElement;\n\n /**\n * Content layout. Default is label-only; pass `icon` for icon-only chrome\n * (nav / tool rails) or `icon-label` for leading icon + text.\n */\n @Prop() variant: ButtonUnfilledVariant = 'label';\n\n /** Control density (height, padding, icon, type). */\n @Prop() size: ButtonUnfilledSize = 'md';\n\n /** Use reduced outer geometry when nested inside a control of the same size. */\n @Prop() isInset: boolean = false;\n\n /** Single removes 4px overall; double removes 8px overall (xs stays single). */\n @Prop() insetDepth: ControlInsetDepth = 'single';\n\n /** Width fit — hug content (default) or fill the parent. */\n @Prop() width: ButtonUnfilledWidth = 'hug';\n\n /** Visible text for `label` / `icon-label` variants. */\n @Prop() label: string = '';\n\n /** Icon name passed to <ds-icon> for `icon` / `icon-label` variants. */\n @Prop() icon: string = '';\n\n /**\n * Owner-controlled visual emphasis inside a composite. This does not add\n * toggle semantics and does not emit dsChange.\n */\n @Prop() isActive: boolean = false;\n\n /**\n * When active, render the selected interaction fill.\n * Default `true` for general UI. Shell chrome (nav / tool rails) should pass\n * `false` so selection is foreground-only (primary color, no fill).\n */\n @Prop() activeFill: boolean = true;\n\n /** Show a 1px secondary inset border. Default on; shell chrome can pass `false`. */\n @Prop() hasBorder: boolean = true;\n\n /** Use the half-radius treatment instead of the default control radius. */\n @Prop() rounded: boolean = false;\n\n /**\n * Scale down during a physical pointer press.\n * Disable when an owning composite requires fixed child or background geometry.\n */\n @Prop() pressScale: boolean = true;\n\n /** Show a notification dot at the top-right of the icon zone (icon variant only). */\n @Prop() dot: boolean = false;\n\n /** Disables interaction. */\n @Prop() isInactive: boolean = false;\n\n /** Shows an inline loader and prevents interaction without applying inactive opacity. */\n @Prop() isLoading: boolean = false;\n\n /** Native button type. */\n @Prop() type: 'button' | 'submit' | 'reset' = 'button';\n\n /** Actual parent surface context. Omit on primary and secondary surfaces. */\n @Prop() background: ButtonUnfilledBackground | undefined;\n\n /** Accessible name override. Required for icon-only buttons. */\n @Prop({ attribute: 'aria-label' }) ariaLabel: string | null = null;\n @Prop() controls: string | undefined;\n /**\n * Controlled disclosure or popup state forwarded to `aria-expanded`.\n * Popup triggers hold only the pressed wash while open; their resting\n * foreground is unchanged. Ordinary disclosures remain visually neutral.\n */\n @Prop() expanded: boolean | undefined;\n @Prop() haspopup: ButtonUnfilledPopup | undefined;\n /**\n * Controlled state for a genuine toggle button. Adds aria-pressed, promotes\n * active styling, and makes activation emit dsChange with the requested state.\n */\n @Prop() pressed: boolean | undefined;\n\n /**\n * Mark the button as a Menu trigger. Implies `aria-haspopup=\"menu\"` and covers\n * both menu-button shapes:\n *\n * - `label` / `icon-label` — the action *has* a menu; a trailing chevron carries\n * the affordance.\n * - `icon` — the button *is* a menu, i.e. the overflow / more-options control.\n * No chevron is added, so the glyph must convey it on its own; use `Ellipses`.\n *\n * Use `haspopup` directly for non-menu popups.\n */\n @Prop() hasMenu: boolean = false;\n\n /**\n * Native `tabindex` for roving keyboard groups in shell chrome.\n * Omit for the default button tab stop (`0`).\n */\n @Prop({ attribute: 'tab-index' }) focusTabIndex?: number;\n\n @Event() dsClick!: EventEmitter<MouseEvent>;\n @Event() dsChange!: EventEmitter<boolean>;\n\n private buttonEl: HTMLButtonElement | null = null;\n\n @Method()\n async setFocus() {\n this.buttonEl?.focus();\n }\n\n private handleClick = (event: MouseEvent) => {\n if (this.isInactive || this.isLoading) {\n event.preventDefault();\n event.stopPropagation();\n return;\n }\n this.dsClick.emit(event);\n if (this.pressed !== undefined) this.dsChange.emit(!this.pressed);\n };\n\n private get showDot(): boolean {\n return this.variant === 'icon' && this.dot && !this.isLoading;\n }\n\n private get resolvedHaspopup(): ButtonUnfilledPopup | undefined {\n return this.haspopup ?? (this.hasMenu ? 'menu' : undefined);\n }\n\n private get doubleInset(): boolean {\n return this.isInset && this.insetDepth === 'double' && this.size !== 'xs';\n }\n\n /** Only popup triggers hold a pressed visual while expanded. */\n private get expandedPopup(): boolean {\n return this.expanded === true && this.resolvedHaspopup !== undefined && !this.isInactive;\n }\n\n private get visuallyActive(): boolean {\n return this.isActive || this.pressed === true;\n }\n\n /** Knock-out ring: selected fill → active wash; otherwise surface token. */\n private get dotRing(): string {\n if ((this.isActive || this.pressed === true) && this.activeFill) {\n return 'var(--ds-interaction-active)';\n }\n return 'var(--ds-button-unfilled-dot-ring)';\n }\n\n private get accessibleName(): string | undefined {\n if (this.ariaLabel) return this.ariaLabel;\n if (this.isLoading && this.variant === 'label' && this.label) return this.label;\n return undefined;\n }\n\n render() {\n const bg = this.background;\n const cls: Record<string, boolean> = {\n 'button-unfilled': true,\n 'ds-button': true,\n 'ds-focus-ring-inset': true,\n 'ds-control-press-scale': this.pressScale,\n 'ds-interaction-fill': true,\n 'ds-interaction-fill--selected':\n (this.isActive || this.pressed === true) &&\n this.activeFill &&\n !this.isInactive,\n 'ds-interaction-fill--on-faint': bg === 'faint',\n 'ds-interaction-fill--on-medium': bg === 'medium',\n 'ds-interaction-fill--on-bold': bg === 'bold',\n 'ds-interaction-fill--on-strong': bg === 'strong',\n 'ds-interaction-fill--on-translucent': bg === 'translucent',\n 'ds-interaction-fill--on-inverted': bg === 'inverted',\n 'ds-interaction-fill--on-media': bg === 'media',\n 'ds-interaction-fill--on-always-dark': bg === 'always-dark',\n 'button-unfilled--active': this.visuallyActive,\n 'button-unfilled--expanded': this.expandedPopup,\n 'ds-button--expanded': this.expandedPopup,\n 'button-unfilled--bordered': this.hasBorder,\n 'ds-button--bordered': this.hasBorder,\n 'button-unfilled--rounded': this.rounded,\n 'ds-button--rounded': this.rounded,\n 'ds-control-inactive': this.isInactive,\n 'ds-control--lg': this.size === 'lg',\n 'ds-control--md': this.size === 'md',\n 'ds-control--sm': this.size === 'sm',\n 'ds-control--xs': this.size === 'xs',\n 'ds-control--inset': this.isInset && !this.doubleInset,\n 'ds-control--inset-double': this.doubleInset,\n 'ds-control-frame': true,\n 'button-unfilled--icon': this.variant === 'icon',\n 'ds-button--icon': this.variant === 'icon',\n 'button-unfilled--label': this.variant === 'label',\n 'button-unfilled--icon-label': this.variant === 'icon-label',\n 'button-unfilled--background-faint': bg === 'faint',\n 'button-unfilled--background-medium': bg === 'medium',\n 'button-unfilled--background-bold': bg === 'bold',\n 'button-unfilled--background-strong': bg === 'strong',\n 'button-unfilled--background-translucent': bg === 'translucent',\n 'button-unfilled--background-inverted': bg === 'inverted',\n 'button-unfilled--background-media': bg === 'media',\n 'button-unfilled--on-always-dark': bg === 'always-dark',\n };\n\n return (\n <Host\n class={{\n 'button-unfilled-host': true,\n 'button-unfilled-host--icon': this.variant === 'icon',\n 'ds-button-host--icon': this.variant === 'icon',\n 'ds-control--lg': this.size === 'lg',\n 'ds-control--md': this.size === 'md',\n 'ds-control--sm': this.size === 'sm',\n 'ds-control--xs': this.size === 'xs',\n 'ds-control--inset': this.isInset && !this.doubleInset,\n 'ds-control--inset-double': this.doubleInset,\n ...controlWidthClass(this.width),\n }}\n tabIndex={-1}\n >\n <button\n ref={el => {\n this.buttonEl = el ?? null;\n }}\n type={this.type}\n class={cls}\n disabled={this.isInactive}\n tabIndex={this.focusTabIndex ?? 0}\n aria-label={this.accessibleName}\n aria-busy={this.isLoading ? 'true' : undefined}\n aria-disabled={this.isLoading ? 'true' : undefined}\n aria-controls={this.controls}\n aria-expanded={this.expanded === undefined ? undefined : String(this.expanded)}\n aria-haspopup={this.resolvedHaspopup}\n aria-pressed={this.pressed === undefined ? undefined : String(this.pressed)}\n onPointerDown={event =>\n beginElevatedControlPress(\n event,\n this.pressScale && !this.isInactive && !this.isLoading,\n )\n }\n onClick={this.handleClick}\n >\n {renderButtonContent({\n namespace: 'button-unfilled',\n variant: this.variant,\n size: this.size,\n label: this.label,\n icon: this.icon,\n hasMenu: this.hasMenu,\n isLoading: this.isLoading,\n dot: {\n visible: this.showDot,\n background: this.dotRing,\n },\n })}\n </button>\n </Host>\n );\n }\n}\n",
621
621
  "type": "registry:ui"
622
622
  }
623
623
  ]
@@ -208,8 +208,8 @@
208
208
  },
209
209
  "peerDependencies": {
210
210
  "required": [
211
- "@ds-mo/tokens ^6.2.1",
212
- "@ds-mo/icons ^6.6.0"
211
+ "@ds-mo/tokens ^6.2.2",
212
+ "@ds-mo/icons ^7.0.0"
213
213
  ],
214
214
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
215
215
  }
@@ -12,7 +12,7 @@
12
12
  "intentStatus": "complete",
13
13
  "intent": {
14
14
  "audience": "general",
15
- "status": "experimental",
15
+ "status": "stable",
16
16
  "summary": "Page-level summary with a persistent 48px reporting bar above an equal-track grid of an optional safety score and comparable measures, plus an elevated page-controlled collapse surface.",
17
17
  "useWhen": [
18
18
  "A page opens with one headline figure that the surrounding measures explain.",
@@ -345,8 +345,8 @@
345
345
  "complexPropertyNote": "Assign these non-primitive values as JavaScript properties: metrics, score.",
346
346
  "peerDependencies": {
347
347
  "required": [
348
- "@ds-mo/tokens ^6.2.1",
349
- "@ds-mo/icons ^6.6.0"
348
+ "@ds-mo/tokens ^6.2.2",
349
+ "@ds-mo/icons ^7.0.0"
350
350
  ],
351
351
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
352
352
  }
@@ -249,8 +249,8 @@
249
249
  },
250
250
  "peerDependencies": {
251
251
  "required": [
252
- "@ds-mo/tokens ^6.2.1",
253
- "@ds-mo/icons ^6.6.0"
252
+ "@ds-mo/tokens ^6.2.2",
253
+ "@ds-mo/icons ^7.0.0"
254
254
  ],
255
255
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
256
256
  }
@@ -267,8 +267,8 @@
267
267
  "complexPropertyNote": "Assign these non-primitive values as JavaScript properties: items.",
268
268
  "peerDependencies": {
269
269
  "required": [
270
- "@ds-mo/tokens ^6.2.1",
271
- "@ds-mo/icons ^6.6.0"
270
+ "@ds-mo/tokens ^6.2.2",
271
+ "@ds-mo/icons ^7.0.0"
272
272
  ],
273
273
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
274
274
  }
@@ -229,8 +229,8 @@
229
229
  "complexPropertyNote": "Assign these non-primitive values as JavaScript properties: definition.",
230
230
  "peerDependencies": {
231
231
  "required": [
232
- "@ds-mo/tokens ^6.2.1",
233
- "@ds-mo/icons ^6.6.0"
232
+ "@ds-mo/tokens ^6.2.2",
233
+ "@ds-mo/icons ^7.0.0"
234
234
  ],
235
235
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
236
236
  }
@@ -349,8 +349,8 @@
349
349
  },
350
350
  "peerDependencies": {
351
351
  "required": [
352
- "@ds-mo/tokens ^6.2.1",
353
- "@ds-mo/icons ^6.6.0"
352
+ "@ds-mo/tokens ^6.2.2",
353
+ "@ds-mo/icons ^7.0.0"
354
354
  ],
355
355
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
356
356
  }
@@ -245,8 +245,8 @@
245
245
  },
246
246
  "peerDependencies": {
247
247
  "required": [
248
- "@ds-mo/tokens ^6.2.1",
249
- "@ds-mo/icons ^6.6.0"
248
+ "@ds-mo/tokens ^6.2.2",
249
+ "@ds-mo/icons ^7.0.0"
250
250
  ],
251
251
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
252
252
  }
@@ -128,8 +128,8 @@
128
128
  },
129
129
  "peerDependencies": {
130
130
  "required": [
131
- "@ds-mo/tokens ^6.2.1",
132
- "@ds-mo/icons ^6.6.0"
131
+ "@ds-mo/tokens ^6.2.2",
132
+ "@ds-mo/icons ^7.0.0"
133
133
  ],
134
134
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
135
135
  }
@@ -253,8 +253,8 @@
253
253
  },
254
254
  "peerDependencies": {
255
255
  "required": [
256
- "@ds-mo/tokens ^6.2.1",
257
- "@ds-mo/icons ^6.6.0"
256
+ "@ds-mo/tokens ^6.2.2",
257
+ "@ds-mo/icons ^7.0.0"
258
258
  ],
259
259
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
260
260
  }
@@ -110,8 +110,8 @@
110
110
  },
111
111
  "peerDependencies": {
112
112
  "required": [
113
- "@ds-mo/tokens ^6.2.1",
114
- "@ds-mo/icons ^6.6.0"
113
+ "@ds-mo/tokens ^6.2.2",
114
+ "@ds-mo/icons ^7.0.0"
115
115
  ],
116
116
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
117
117
  }
@@ -102,8 +102,8 @@
102
102
  },
103
103
  "peerDependencies": {
104
104
  "required": [
105
- "@ds-mo/tokens ^6.2.1",
106
- "@ds-mo/icons ^6.6.0"
105
+ "@ds-mo/tokens ^6.2.2",
106
+ "@ds-mo/icons ^7.0.0"
107
107
  ],
108
108
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
109
109
  }
@@ -198,8 +198,8 @@
198
198
  },
199
199
  "peerDependencies": {
200
200
  "required": [
201
- "@ds-mo/tokens ^6.2.1",
202
- "@ds-mo/icons ^6.6.0"
201
+ "@ds-mo/tokens ^6.2.2",
202
+ "@ds-mo/icons ^7.0.0"
203
203
  ],
204
204
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
205
205
  }
@@ -170,8 +170,8 @@
170
170
  },
171
171
  "peerDependencies": {
172
172
  "required": [
173
- "@ds-mo/tokens ^6.2.1",
174
- "@ds-mo/icons ^6.6.0"
173
+ "@ds-mo/tokens ^6.2.2",
174
+ "@ds-mo/icons ^7.0.0"
175
175
  ],
176
176
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
177
177
  }
@@ -193,8 +193,8 @@
193
193
  },
194
194
  "peerDependencies": {
195
195
  "required": [
196
- "@ds-mo/tokens ^6.2.1",
197
- "@ds-mo/icons ^6.6.0"
196
+ "@ds-mo/tokens ^6.2.2",
197
+ "@ds-mo/icons ^7.0.0"
198
198
  ],
199
199
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
200
200
  }
@@ -41,7 +41,7 @@
41
41
  "component:ds-checkbox"
42
42
  ],
43
43
  "accessibility": [
44
- "The built-in select trigger exposes combobox semantics with a dialog popup, synchronizes expanded state, and restores focus after dismissal.",
44
+ "The built-in select trigger exposes combobox semantics with a dialog popup, synchronizes expanded state, holds the pressed interaction wash without changing resting foreground while open, and restores focus after dismissal.",
45
45
  "The category pane is a vertical tab list. Arrow Up and Arrow Down wrap, Home and End jump, Arrow Right enters the option pane, and Arrow Left returns to the active category.",
46
46
  "Escape and outside activation close the popup and restore trigger focus. Tab moves through option controls and closes only when it leaves the popup boundary.",
47
47
  "The trigger count reports active filter categories. Category counts and the footer report individual selected criteria; none replace visible selected-state indication."
@@ -50,6 +50,7 @@
50
50
  "The application owns filters, values, activeFilterId, and open. Replace controlled objects and arrays after dsChange, dsActiveFilterChange, dsClear, or dsClose.",
51
51
  "Single filters return a string, multiple filters return ordered string arrays, boolean filters return a boolean, and date filters return a YYYY-MM-DD string.",
52
52
  "Selection applies immediately and does not close the popup.",
53
+ "Changing category replaces the option-pane subtree so checkbox and other option visuals mount in their new state instead of transitioning from the previous category's row state.",
53
54
  "The trigger reports how many filter categories have selections, while the footer appears when values are selected and reports the total selected criteria."
54
55
  ],
55
56
  "responsiveBehavior": [
@@ -608,8 +609,8 @@
608
609
  "complexPropertyNote": "Assign these non-primitive values as JavaScript properties: anchor, filters, values.",
609
610
  "peerDependencies": {
610
611
  "required": [
611
- "@ds-mo/tokens ^6.2.1",
612
- "@ds-mo/icons ^6.6.0"
612
+ "@ds-mo/tokens ^6.2.2",
613
+ "@ds-mo/icons ^7.0.0"
613
614
  ],
614
615
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
615
616
  }
@@ -636,7 +637,7 @@
636
637
  },
637
638
  {
638
639
  "path": "src/wc/components/FilterMenu/FilterMenu.tsx",
639
- "content": "import {\n Component,\n Element,\n Event,\n EventEmitter,\n h,\n Host,\n Listen,\n Prop,\n State,\n Watch,\n} from '@stencil/core';\nimport {\n CONTROL_TEXT_VARIANT,\n controlWidthClass,\n resolveChoicePopupAlignOffset,\n resolveCssLengthPx,\n resolveMotionTimeMs,\n TOKEN_CSS_LENGTHS,\n TOKEN_DEFAULTS,\n type ControlWidth,\n} from '../../utils';\nimport type { AnchoredAlign, AnchoredSide } from '../../utils/anchored-position';\nimport { AnchoredPositionController } from '../../utils/anchored-position-controller';\nimport { ChoiceOptionRow } from '../../utils/choice-list-parts';\nimport { choiceListUsesSubtext } from '../../utils/choice-list';\n\nexport type FilterMenuFilterKind = 'single' | 'multiple' | 'boolean' | 'date';\nexport type FilterMenuSize = 'lg' | 'md' | 'sm' | 'xs';\nexport type FilterMenuWidth = ControlWidth;\n\nexport interface FilterMenuOption {\n label: string;\n value: string;\n description?: string;\n isInactive?: boolean;\n}\n\nexport interface FilterMenuFilter {\n id: string;\n label: string;\n kind: FilterMenuFilterKind;\n options?: FilterMenuOption[];\n description?: string;\n fieldLabel?: string;\n /** Draw a standard menu-section divider after this category. */\n divider?: boolean;\n}\n\nexport type FilterMenuValue = string[] | boolean | string;\nexport type FilterMenuValues = Record<string, FilterMenuValue | undefined>;\n\nexport interface FilterMenuChangeDetail {\n filterId: string;\n value: FilterMenuValue;\n}\n\nconst FILTER_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\nlet filterMenuId = 0;\n\nconst ICON_SIZE: Record<FilterMenuSize, 'lg' | 'md' | 'sm' | 'xs'> = {\n lg: 'lg',\n md: 'md',\n sm: 'sm',\n xs: 'xs',\n};\n\n@Component({\n tag: 'ds-filter-menu',\n styleUrl: 'FilterMenu.css',\n scoped: true,\n})\nexport class FilterMenu {\n @Element() el!: HTMLElement;\n\n /** Controlled popup visibility. */\n @Prop({ mutable: true }) open: boolean = false;\n /** Select trigger text. The selected count is appended automatically. */\n @Prop() triggerLabel: string = 'Filters';\n /** Optional select trigger prefix icon name. */\n @Prop() icon: string | undefined = 'Filters';\n /** Select trigger density. */\n @Prop() size: FilterMenuSize = 'md';\n /** Select trigger width fit. */\n @Prop() width: FilterMenuWidth = 'hug';\n /** Show the surface-aware inset border around the select trigger. */\n @Prop() hasBorder: boolean = true;\n /** Show selected interaction fill when one or more criteria are active. */\n @Prop() activeFill: boolean = false;\n /** ID applied to the internal select trigger. */\n @Prop() inputId: string | undefined;\n /** Direct accessible name for the internal select trigger. */\n @Prop({ attribute: 'aria-label' }) ariaLabel: string | null = null;\n /** Product-owned filter categories and option definitions. */\n @Prop() filters: FilterMenuFilter[] = [];\n /** Controlled values keyed by filter id. */\n @Prop() values: FilterMenuValues = {};\n /** Controlled category shown in the option pane. */\n @Prop() activeFilterId: string | undefined;\n /** External trigger element to position against. */\n @Prop() anchor: HTMLElement | undefined;\n /** ID of the external trigger element used for positioning and focus return. */\n @Prop() anchorId: string | undefined;\n /** Preferred popup side; collision handling may flip it. */\n @Prop() side: AnchoredSide = 'bottom';\n /** Cross-axis alignment against the trigger. */\n @Prop() align: AnchoredAlign = 'end';\n /** Gap between trigger and popup — number in px or a TokoMo length. */\n @Prop() sideOffset: number | string = TOKEN_CSS_LENGTHS.space050;\n /** Cross-axis offset — number in px or a TokoMo length. */\n @Prop() alignOffset: number | string = 0;\n /** Popup width. It remains clamped to the viewport by the component recipe. */\n @Prop() menuWidth: string = TOKEN_CSS_LENGTHS.menuWidthLg;\n /** Accessible name for the non-modal filter dialog. */\n @Prop() menuLabel: string = 'Filters';\n /** Accessible name for the category tab list. */\n @Prop() categoriesLabel: string = 'Filter categories';\n /** Footer action and date-clear accessible label. */\n @Prop() clearLabel: string = 'Clear';\n /** Show a visible focus ring on initial entry after keyboard activation. */\n @Prop() initialFocusVisible: boolean = false;\n\n @State() private shouldRender = false;\n @State() private closing = false;\n @State() private positionReady = false;\n @State() private pos: { x: number; y: number } = { x: 0, y: 0 };\n @State() private focusRingVisible = false;\n @State() private activeOptionIndex = 0;\n\n /** Requests a controlled value replacement without closing the popup. */\n @Event() dsChange!: EventEmitter<FilterMenuChangeDetail>;\n /** Requests that the consumer clear every filter value. */\n @Event() dsClear!: EventEmitter<void>;\n /** Requests a controlled active-category replacement. */\n @Event() dsActiveFilterChange!: EventEmitter<string>;\n /** Requests that the controlled popup close. */\n @Event() dsClose!: EventEmitter<void>;\n /** Emitted whenever internal select-trigger activation changes popup visibility. */\n @Event() dsOpenChange!: EventEmitter<boolean>;\n /** Emitted after exit motion and rendered popup removal complete. */\n @Event() dsAfterClose!: EventEmitter<void>;\n\n private readonly generatedId = `ds-filter-menu-${++filterMenuId}`;\n private outsideHandler: ((event: MouseEvent) => void) | null = null;\n private triggerElement: HTMLButtonElement | null = null;\n private pendingInitialFocusVisible = false;\n private closeTimer: ReturnType<typeof setTimeout> | null = null;\n private closingSnapshot: {\n filters: FilterMenuFilter[];\n values: FilterMenuValues;\n activeFilterId: string | undefined;\n } | null = null;\n\n private readonly position = new AnchoredPositionController({\n getAnchor: () => this.resolvedAnchor,\n getPopup: () => this.el.querySelector<HTMLElement>('.filter-menu-popup'),\n measure: (anchor, popup) => {\n if (!this.open) return null;\n const anchorRect = anchor.getBoundingClientRect();\n const sectionInsetPx = resolveCssLengthPx(\n TOKEN_DEFAULTS.space050,\n TOKEN_DEFAULTS.space050\n );\n return {\n anchorRect,\n popupWidth: popup.offsetWidth || this.popupFallbackWidth,\n popupHeight: popup.offsetHeight || this.popupFallbackHeight,\n side: this.side,\n align: this.align,\n sideOffsetPx: resolveCssLengthPx(this.sideOffset, TOKEN_DEFAULTS.space050),\n alignOffsetPx: resolveChoicePopupAlignOffset({\n align: this.align,\n alignOffsetPx: resolveCssLengthPx(this.alignOffset, 0),\n sectionInsetPx,\n }),\n viewportPadPx: this.viewportPad,\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 observeResize: true,\n topLayer: true,\n });\n\n componentDidLoad() {\n if (this.open) this.onOpenChange(true);\n }\n\n disconnectedCallback() {\n this.position.unobserve();\n this.teardownListeners();\n }\n\n @Watch('open')\n onOpenChange(isOpen: boolean) {\n if (isOpen) {\n this.teardownListeners();\n this.closingSnapshot = null;\n this.shouldRender = true;\n this.closing = false;\n this.positionReady = false;\n this.focusRingVisible = this.pendingInitialFocusVisible || this.initialFocusVisible;\n this.setupListeners();\n this.position.schedule(() => this.focusInitialCategory());\n return;\n }\n\n if (!this.shouldRender) return;\n this.position.cancel();\n this.captureClosingSnapshot();\n this.closing = true;\n this.teardownListeners();\n const duration = resolveMotionTimeMs(\n TOKEN_DEFAULTS.motionShort2,\n TOKEN_DEFAULTS.animationDurationShort3\n );\n if (duration <= 0) {\n this.finishClose();\n return;\n }\n this.closeTimer = setTimeout(() => this.finishClose(), duration);\n }\n\n @Watch('anchor')\n @Watch('anchorId')\n @Watch('side')\n @Watch('align')\n @Watch('sideOffset')\n @Watch('alignOffset')\n @Watch('menuWidth')\n onPositionInputChange() {\n if (this.open) this.position.schedule();\n }\n\n @Listen('keydown')\n handleKeyDown(event: KeyboardEvent) {\n if (!this.shouldRender || this.closing) return;\n if (event.key === 'Escape') {\n event.preventDefault();\n this.close();\n return;\n }\n if (event.key !== 'Tab' || !this.tabLeavesPopup(event)) return;\n event.preventDefault();\n this.close(false);\n this.moveFocusAfterTab(event.shiftKey);\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 this.triggerElement;\n }\n\n private get usesInternalTrigger(): boolean {\n return !this.anchor && !this.anchorId;\n }\n\n private get anchorFocusTarget(): HTMLElement | null {\n const anchor = this.resolvedAnchor;\n if (!anchor) return null;\n if (anchor.matches(FILTER_MENU_FOCUSABLE_SELECTOR)) return anchor;\n return anchor.querySelector<HTMLElement>(FILTER_MENU_FOCUSABLE_SELECTOR) ?? anchor;\n }\n\n private get viewportPad(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.space050, TOKEN_DEFAULTS.space050);\n }\n\n private get popupFallbackWidth(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.menuWidthLg, TOKEN_DEFAULTS.menuWidthLg);\n }\n\n private get popupFallbackHeight(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.menuFallbackHeight, TOKEN_DEFAULTS.menuFallbackHeight);\n }\n\n private setupListeners() {\n this.outsideHandler = event => {\n const target = event.target as Node;\n const popup = this.el.querySelector('.filter-menu-popup');\n if (popup?.contains(target) || this.resolvedAnchor?.contains(target)) return;\n this.close();\n };\n document.addEventListener('mousedown', this.outsideHandler, true);\n this.position.observe();\n }\n\n private teardownListeners() {\n this.position.unobserve();\n if (this.outsideHandler) {\n document.removeEventListener('mousedown', this.outsideHandler, true);\n this.outsideHandler = null;\n }\n if (this.closeTimer) {\n clearTimeout(this.closeTimer);\n this.closeTimer = null;\n }\n }\n\n private close(restoreFocus = true) {\n this.captureClosingSnapshot();\n if (restoreFocus) this.focusAnchor();\n this.dsOpenChange.emit(false);\n this.dsClose.emit();\n this.open = false;\n this.onOpenChange(false);\n }\n\n private toggleFromTrigger(event: MouseEvent) {\n if (this.open) {\n this.close();\n return;\n }\n this.pendingInitialFocusVisible = event.detail === 0;\n this.open = true;\n this.dsOpenChange.emit(true);\n this.onOpenChange(true);\n }\n\n private handleTriggerKeyDown(event: KeyboardEvent) {\n if (event.key !== 'ArrowDown' || this.open) return;\n event.preventDefault();\n this.pendingInitialFocusVisible = true;\n this.open = true;\n this.dsOpenChange.emit(true);\n this.onOpenChange(true);\n }\n\n private finishClose() {\n const popup = this.el.querySelector<HTMLElement>('.filter-menu-popup');\n if (popup?.matches(':popover-open')) popup.hidePopover();\n this.shouldRender = false;\n this.closing = false;\n this.closingSnapshot = null;\n this.closeTimer = null;\n this.dsAfterClose.emit();\n }\n\n private captureClosingSnapshot() {\n if (this.closingSnapshot) return;\n this.closingSnapshot = {\n filters: this.filters.map(filter => ({\n ...filter,\n options: filter.options?.map(option => ({ ...option })),\n })),\n values: Object.fromEntries(\n Object.entries(this.values).map(([key, value]) => [\n key,\n Array.isArray(value) ? [...value] : value,\n ])\n ),\n activeFilterId: this.activeFilterId,\n };\n }\n\n private focusAnchor() {\n const anchor = this.resolvedAnchor as\n (HTMLElement & { setFocus?: () => Promise<void> | void }) | null;\n if (anchor?.setFocus) {\n void anchor.setFocus();\n return;\n }\n this.anchorFocusTarget?.focus();\n }\n\n private focusInitialCategory() {\n requestAnimationFrame(() => {\n this.el.querySelector<HTMLElement>('.filter-menu__category[tabindex=\"0\"]')?.focus();\n this.pendingInitialFocusVisible = false;\n });\n }\n\n private tabLeavesPopup(event: KeyboardEvent): boolean {\n const popup = this.el.querySelector<HTMLElement>('.filter-menu-popup');\n if (!popup) return true;\n const focusables = Array.from(\n popup.querySelectorAll<HTMLElement>(FILTER_MENU_FOCUSABLE_SELECTOR)\n ).filter(element => element.getClientRects().length > 0);\n const index = focusables.indexOf(event.target as HTMLElement);\n if (index < 0) return true;\n return event.shiftKey ? index === 0 : index === focusables.length - 1;\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 const candidates = Array.from(\n document.querySelectorAll<HTMLElement>(FILTER_MENU_FOCUSABLE_SELECTOR)\n ).filter(\n element =>\n !element.closest('.filter-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 selectedFilter(filters: FilterMenuFilter[], activeFilterId: string | undefined) {\n return filters.find(filter => filter.id === activeFilterId) ?? filters[0];\n }\n\n private selectedCount(filter: FilterMenuFilter, values: FilterMenuValues): number {\n const value = values[filter.id];\n if (filter.kind === 'multiple') return Array.isArray(value) ? value.length : 0;\n if (filter.kind === 'boolean') return value === true ? 1 : 0;\n return typeof value === 'string' && value.length > 0 ? 1 : 0;\n }\n\n private totalSelected(filters: FilterMenuFilter[], values: FilterMenuValues): number {\n return filters.reduce((total, filter) => total + this.selectedCount(filter, values), 0);\n }\n\n private activeFilterCount(filters: FilterMenuFilter[], values: FilterMenuValues): number {\n return filters.filter(filter => this.selectedCount(filter, values) > 0).length;\n }\n\n private selectCategory(filterId: string, focus = false) {\n this.activeOptionIndex = 0;\n this.dsActiveFilterChange.emit(filterId);\n if (!focus) return;\n requestAnimationFrame(() => {\n this.el.querySelector<HTMLElement>(`[data-filter-category=\"${filterId}\"]`)?.focus();\n });\n }\n\n private handleCategoryKeyDown(\n event: KeyboardEvent,\n filters: FilterMenuFilter[],\n filterId: string\n ) {\n const currentIndex = filters.findIndex(filter => filter.id === filterId);\n let nextIndex: number;\n if (event.key === 'ArrowDown') nextIndex = (currentIndex + 1) % filters.length;\n else if (event.key === 'ArrowUp')\n nextIndex = (currentIndex - 1 + filters.length) % filters.length;\n else if (event.key === 'Home') nextIndex = 0;\n else if (event.key === 'End') nextIndex = filters.length - 1;\n else if (event.key === 'ArrowRight') {\n event.preventDefault();\n requestAnimationFrame(() => {\n this.el\n .querySelector<HTMLElement>(\n '.filter-menu__options [role=\"option\"], .filter-menu__options input'\n )\n ?.focus();\n });\n return;\n } else return;\n\n event.preventDefault();\n this.focusRingVisible = true;\n this.selectCategory(filters[nextIndex].id, true);\n }\n\n private handlePanelKeyDown(event: KeyboardEvent, filterId: string) {\n if (event.key !== 'ArrowLeft') return;\n event.preventDefault();\n this.el.querySelector<HTMLElement>(`[data-filter-category=\"${filterId}\"]`)?.focus();\n }\n\n private handleOptionKeyDown(\n event: KeyboardEvent,\n filter: FilterMenuFilter,\n option: FilterMenuOption,\n index: number,\n values: FilterMenuValues\n ) {\n const options = filter.options ?? [];\n if (event.key === 'ArrowLeft') {\n this.handlePanelKeyDown(event, filter.id);\n return;\n }\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault();\n this.selectOption(filter, option, values);\n return;\n }\n if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;\n event.preventDefault();\n const enabled = options\n .map((candidate, optionIndex) => ({ candidate, optionIndex }))\n .filter(({ candidate }) => !candidate.isInactive)\n .map(({ optionIndex }) => optionIndex);\n if (!enabled.length) return;\n const current = enabled.indexOf(index);\n let next: number;\n if (event.key === 'Home') next = enabled[0];\n else if (event.key === 'End') next = enabled[enabled.length - 1];\n else {\n const direction = event.key === 'ArrowDown' ? 1 : -1;\n const start = current >= 0 ? current : direction === 1 ? -1 : 0;\n next = enabled[(start + direction + enabled.length) % enabled.length];\n }\n this.activeOptionIndex = next;\n this.focusRingVisible = true;\n requestAnimationFrame(() => {\n this.el.querySelector<HTMLElement>(`#${this.generatedId}-${filter.id}-option-${next}`)?.focus();\n });\n }\n\n private toggleMultiple(\n filter: FilterMenuFilter,\n option: FilterMenuOption,\n checked: boolean,\n values: FilterMenuValues\n ) {\n if (option.isInactive) return;\n const current = Array.isArray(values[filter.id]) ? (values[filter.id] as string[]) : [];\n const selected = new Set(current);\n if (checked) selected.add(option.value);\n else selected.delete(option.value);\n const ordered = (filter.options ?? [])\n .map(candidate => candidate.value)\n .filter(value => selected.has(value));\n this.dsChange.emit({ filterId: filter.id, value: ordered });\n }\n\n private selectOption(\n filter: FilterMenuFilter,\n option: FilterMenuOption,\n values: FilterMenuValues\n ) {\n if (option.isInactive) return;\n if (filter.kind === 'single') {\n this.dsChange.emit({ filterId: filter.id, value: option.value });\n return;\n }\n const selected = Array.isArray(values[filter.id])\n ? (values[filter.id] as string[]).includes(option.value)\n : false;\n this.toggleMultiple(filter, option, !selected, values);\n }\n\n private renderOptions(filter: FilterMenuFilter, values: FilterMenuValues) {\n const value = values[filter.id];\n if (filter.kind === 'multiple' || filter.kind === 'single') {\n const selected = Array.isArray(value) ? value : [];\n const options = filter.options ?? [];\n const usesSubtext = choiceListUsesSubtext(\n options.map(option => ({\n label: option.label,\n value: option.value,\n subtext: option.description,\n }))\n );\n return options.map((option, index) => {\n const isSelected =\n filter.kind === 'multiple'\n ? selected.includes(option.value)\n : value === option.value;\n return (\n <ChoiceOptionRow\n size=\"md\"\n id={`${this.generatedId}-${filter.id}-option-${index}`}\n option={{\n label: option.label,\n value: option.value,\n subtext: option.description,\n isInactive: option.isInactive,\n }}\n selected={isSelected}\n active={index === this.activeOptionIndex}\n focusRingVisible={this.focusRingVisible}\n usesSubtext={usesSubtext}\n tabIndex={index === this.activeOptionIndex ? 0 : -1}\n leading={\n filter.kind === 'multiple' ? (\n <span\n class=\"ds-choice-item__icon ds-control-icon-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n <ds-checkbox label=\"\" size=\"md\" checked={isSelected} presentation />\n </span>\n ) : undefined\n }\n onFocus={() => {\n this.activeOptionIndex = index;\n }}\n onKeyDown={(event: KeyboardEvent) =>\n this.handleOptionKeyDown(event, filter, option, index, values)\n }\n onHover={() => {\n this.focusRingVisible = false;\n this.activeOptionIndex = index;\n }}\n onSelect={() => this.selectOption(filter, option, values)}\n />\n );\n });\n }\n\n if (filter.kind === 'boolean') {\n const option: FilterMenuOption = {\n label: filter.fieldLabel ?? filter.label,\n value: 'true',\n description: filter.description,\n };\n return (\n <ChoiceOptionRow\n size=\"md\"\n id={`${this.generatedId}-${filter.id}-option-0`}\n option={{\n label: option.label,\n value: option.value,\n subtext: option.description,\n }}\n selected={value === true}\n active\n focusRingVisible={this.focusRingVisible}\n usesSubtext={Boolean(option.description)}\n tabIndex={0}\n leading={\n <span\n class=\"ds-choice-item__icon ds-control-icon-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n <ds-checkbox label=\"\" size=\"md\" checked={value === true} presentation />\n </span>\n }\n onKeyDown={(event: KeyboardEvent) => {\n if (event.key === 'ArrowLeft') this.handlePanelKeyDown(event, filter.id);\n else if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault();\n this.dsChange.emit({ filterId: filter.id, value: value !== true });\n }\n }}\n onHover={() => {\n this.focusRingVisible = false;\n }}\n onSelect={() => this.dsChange.emit({ filterId: filter.id, value: value !== true })}\n />\n );\n }\n\n const dateValue = typeof value === 'string' ? value : '';\n const inputId = `${this.generatedId}-${filter.id}-input`;\n return (\n <div class=\"filter-menu__date-field\">\n <label htmlFor={inputId}>\n <ds-text as=\"span\" variant=\"text-body-small\" emphasis color=\"primary\">\n {filter.fieldLabel ?? 'Event date'}\n </ds-text>\n </label>\n <div class=\"filter-menu__date-control ds-control-frame ds-control--md\">\n <input\n id={inputId}\n class=\"filter-menu__date-input\"\n type=\"date\"\n value={dateValue}\n onInput={(event: Event) =>\n this.dsChange.emit({\n filterId: filter.id,\n value: (event.target as HTMLInputElement).value,\n })\n }\n />\n {dateValue ? (\n <ds-button-unfilled\n variant=\"icon\"\n icon=\"CrossCircle\"\n size=\"sm\"\n isInset\n hasBorder={false}\n rounded\n ariaLabel={`${this.clearLabel} ${filter.fieldLabel ?? filter.label}`}\n onDsClick={(event: CustomEvent<MouseEvent>) => {\n event.stopPropagation();\n this.dsChange.emit({ filterId: filter.id, value: '' });\n }}\n />\n ) : null}\n </div>\n </div>\n );\n }\n\n render() {\n const state =\n this.closing && this.closingSnapshot\n ? this.closingSnapshot\n : {\n filters: this.filters,\n values: this.values,\n activeFilterId: this.activeFilterId,\n };\n const activeFilter = this.selectedFilter(state.filters, state.activeFilterId);\n const totalSelected = this.totalSelected(state.filters, state.values);\n const activeFilterCount = this.activeFilterCount(state.filters, state.values);\n const hasActiveFilters = activeFilterCount > 0;\n const label = `${this.triggerLabel}${hasActiveFilters ? ` · ${activeFilterCount}` : ''}`;\n const textVariant = CONTROL_TEXT_VARIANT[this.size];\n const iconSize = ICON_SIZE[this.size];\n const popupId = `${this.generatedId}-popup`;\n const popupStyle: Record<string, string> = {\n position: 'fixed',\n left: '0',\n top: '0',\n width: this.menuWidth,\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 return (\n <Host\n class={{\n 'filter-menu-host': true,\n 'filter-menu-host--external-trigger': !this.usesInternalTrigger,\n 'ds-select-trigger-host': this.usesInternalTrigger,\n [`ds-control--${this.size}`]: true,\n ...controlWidthClass(this.width),\n }}\n >\n {this.usesInternalTrigger ? (\n <button\n ref={element => {\n this.triggerElement = (element as HTMLButtonElement) ?? null;\n }}\n id={this.inputId ?? `${this.generatedId}-trigger`}\n type=\"button\"\n class={{\n trigger: true,\n 'ds-control-frame': true,\n 'ds-focus-ring-inset': true,\n 'ds-interaction-fill': true,\n 'ds-interaction-fill--selected': this.activeFill && hasActiveFilters,\n 'trigger--bordered': this.hasBorder,\n 'trigger--has-value': hasActiveFilters,\n [`ds-control--${this.size}`]: true,\n }}\n role=\"combobox\"\n aria-haspopup=\"dialog\"\n aria-expanded={String(this.open)}\n aria-controls={this.open ? popupId : undefined}\n aria-label={this.ariaLabel ?? this.menuLabel}\n onClick={(event: MouseEvent) => this.toggleFromTrigger(event)}\n onKeyDown={(event: KeyboardEvent) => this.handleTriggerKeyDown(event)}\n >\n {this.icon ? (\n <span\n class=\"trigger__prefix ds-control-icon-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n <ds-icon name={this.icon} size={iconSize} color=\"inherit\" />\n </span>\n ) : null}\n <ds-text\n class=\"trigger__label-box trigger__label trigger__label-content ds-control-label-box ds-interaction-fill__content\"\n as=\"span\"\n variant={textVariant}\n color=\"inherit\"\n lineTruncation={1}\n >\n {label}\n </ds-text>\n <span\n class=\"trigger__chevron ds-control-icon-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n <ds-icon name=\"ChevronDown\" size={iconSize} color=\"inherit\" />\n </span>\n </button>\n ) : null}\n\n {this.shouldRender ? (\n <div\n id={popupId}\n popover=\"manual\"\n class={{\n 'filter-menu-popup': true,\n 'ds-choice-popup': true,\n 'ds-choice-popup--closing': this.closing,\n }}\n style={popupStyle}\n role=\"dialog\"\n aria-label={this.menuLabel}\n >\n {activeFilter ? (\n <div class=\"filter-menu\">\n <div class=\"filter-menu__body\">\n <div\n class=\"filter-menu__categories ds-choice-list ds-chrome-column ds-chrome-space--sm\"\n role=\"tablist\"\n aria-label={this.categoriesLabel}\n aria-orientation=\"vertical\"\n >\n {state.filters.map(filter => {\n const active = filter.id === activeFilter.id;\n const count = this.selectedCount(filter, state.values);\n return (\n <div\n key={filter.id}\n class={{\n 'filter-menu__category-section': true,\n 'ds-choice-section--divided': Boolean(filter.divider),\n }}\n >\n <button\n id={`${this.generatedId}-${filter.id}-tab`}\n type=\"button\"\n role=\"tab\"\n data-filter-category={filter.id}\n class={{\n 'filter-menu__category': true,\n 'filter-menu__category--active': active,\n 'ds-choice-item': true,\n 'ds-control-frame': true,\n 'ds-control--md': true,\n 'ds-focus-ring-inset': true,\n 'ds-focus-ring--visible': active && this.focusRingVisible,\n 'ds-interaction-fill': true,\n 'ds-interaction-fill--selected': active,\n }}\n aria-selected={String(active)}\n aria-controls={`${this.generatedId}-panel`}\n tabIndex={active ? 0 : -1}\n onMouseDown={() => {\n this.focusRingVisible = false;\n }}\n onClick={() => this.selectCategory(filter.id)}\n onKeyDown={(event: KeyboardEvent) =>\n this.handleCategoryKeyDown(event, state.filters, filter.id)\n }\n >\n <ds-text\n class=\"ds-choice-item__content ds-choice-item__label ds-control-label-box ds-interaction-fill__content\"\n as=\"span\"\n variant=\"text-body-medium\"\n color={active ? 'primary' : 'secondary'}\n >\n {filter.label}\n </ds-text>\n {count > 0 ? (\n <ds-tag\n class=\"ds-choice-item__tag ds-interaction-fill__content\"\n label={String(count)}\n size=\"sm\"\n intent=\"brand\"\n contrast=\"bold\"\n isInset\n rounded\n />\n ) : null}\n </button>\n </div>\n );\n })}\n </div>\n\n <div\n id={`${this.generatedId}-panel`}\n class=\"filter-menu__options\"\n role=\"tabpanel\"\n aria-labelledby={`${this.generatedId}-${activeFilter.id}-tab`}\n >\n <div\n class=\"filter-menu__option-list ds-choice-list ds-chrome-column ds-chrome-space--sm\"\n role=\"listbox\"\n aria-label={activeFilter.label}\n aria-multiselectable={\n activeFilter.kind === 'multiple' ? 'true' : undefined\n }\n >\n {this.renderOptions(activeFilter, state.values)}\n </div>\n </div>\n </div>\n\n {totalSelected > 0 ? (\n <div class=\"filter-menu__footer ds-choice-footer\">\n <div class=\"ds-choice-footer__content ds-control--md\">\n <ds-text\n class=\"ds-choice-footer__summary\"\n as=\"span\"\n variant=\"text-body-medium\"\n color=\"secondary\"\n aria-live=\"polite\"\n >\n {totalSelected} selected\n </ds-text>\n <button\n class=\"filter-menu__clear ds-choice-footer__clear ds-text-action\"\n type=\"button\"\n onClick={() => this.dsClear.emit()}\n >\n <ds-text as=\"span\" variant=\"text-body-medium\" color=\"inherit\">\n {this.clearLabel}\n </ds-text>\n </button>\n </div>\n </div>\n ) : null}\n </div>\n ) : null}\n </div>\n ) : null}\n </Host>\n );\n }\n}\n",
640
+ "content": "import {\n Component,\n Element,\n Event,\n EventEmitter,\n h,\n Host,\n Listen,\n Prop,\n State,\n Watch,\n} from '@stencil/core';\nimport {\n CONTROL_TEXT_VARIANT,\n controlWidthClass,\n resolveChoicePopupAlignOffset,\n resolveCssLengthPx,\n resolveMotionTimeMs,\n TOKEN_CSS_LENGTHS,\n TOKEN_DEFAULTS,\n type ControlWidth,\n} from '../../utils';\nimport type { AnchoredAlign, AnchoredSide } from '../../utils/anchored-position';\nimport { AnchoredPositionController } from '../../utils/anchored-position-controller';\nimport { ChoiceOptionRow } from '../../utils/choice-list-parts';\nimport { choiceListUsesSubtext } from '../../utils/choice-list';\n\nexport type FilterMenuFilterKind = 'single' | 'multiple' | 'boolean' | 'date';\nexport type FilterMenuSize = 'lg' | 'md' | 'sm' | 'xs';\nexport type FilterMenuWidth = ControlWidth;\n\nexport interface FilterMenuOption {\n label: string;\n value: string;\n description?: string;\n isInactive?: boolean;\n}\n\nexport interface FilterMenuFilter {\n id: string;\n label: string;\n kind: FilterMenuFilterKind;\n options?: FilterMenuOption[];\n description?: string;\n fieldLabel?: string;\n /** Draw a standard menu-section divider after this category. */\n divider?: boolean;\n}\n\nexport type FilterMenuValue = string[] | boolean | string;\nexport type FilterMenuValues = Record<string, FilterMenuValue | undefined>;\n\nexport interface FilterMenuChangeDetail {\n filterId: string;\n value: FilterMenuValue;\n}\n\nconst FILTER_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\nlet filterMenuId = 0;\n\nconst ICON_SIZE: Record<FilterMenuSize, 'lg' | 'md' | 'sm' | 'xs'> = {\n lg: 'lg',\n md: 'md',\n sm: 'sm',\n xs: 'xs',\n};\n\n@Component({\n tag: 'ds-filter-menu',\n styleUrl: 'FilterMenu.css',\n scoped: true,\n})\nexport class FilterMenu {\n @Element() el!: HTMLElement;\n\n /** Controlled popup visibility. */\n @Prop({ mutable: true }) open: boolean = false;\n /** Select trigger text. The selected count is appended automatically. */\n @Prop() triggerLabel: string = 'Filters';\n /** Optional select trigger prefix icon name. */\n @Prop() icon: string | undefined = 'Filters';\n /** Select trigger density. */\n @Prop() size: FilterMenuSize = 'md';\n /** Select trigger width fit. */\n @Prop() width: FilterMenuWidth = 'hug';\n /** Show the surface-aware inset border around the select trigger. */\n @Prop() hasBorder: boolean = true;\n /** Show selected interaction fill when one or more criteria are active. */\n @Prop() activeFill: boolean = false;\n /** ID applied to the internal select trigger. */\n @Prop() inputId: string | undefined;\n /** Direct accessible name for the internal select trigger. */\n @Prop({ attribute: 'aria-label' }) ariaLabel: string | null = null;\n /** Product-owned filter categories and option definitions. */\n @Prop() filters: FilterMenuFilter[] = [];\n /** Controlled values keyed by filter id. */\n @Prop() values: FilterMenuValues = {};\n /** Controlled category shown in the option pane. */\n @Prop() activeFilterId: string | undefined;\n /** External trigger element to position against. */\n @Prop() anchor: HTMLElement | undefined;\n /** ID of the external trigger element used for positioning and focus return. */\n @Prop() anchorId: string | undefined;\n /** Preferred popup side; collision handling may flip it. */\n @Prop() side: AnchoredSide = 'bottom';\n /** Cross-axis alignment against the trigger. */\n @Prop() align: AnchoredAlign = 'end';\n /** Gap between trigger and popup — number in px or a TokoMo length. */\n @Prop() sideOffset: number | string = TOKEN_CSS_LENGTHS.space050;\n /** Cross-axis offset — number in px or a TokoMo length. */\n @Prop() alignOffset: number | string = 0;\n /** Popup width. It remains clamped to the viewport by the component recipe. */\n @Prop() menuWidth: string = TOKEN_CSS_LENGTHS.menuWidthLg;\n /** Accessible name for the non-modal filter dialog. */\n @Prop() menuLabel: string = 'Filters';\n /** Accessible name for the category tab list. */\n @Prop() categoriesLabel: string = 'Filter categories';\n /** Footer action and date-clear accessible label. */\n @Prop() clearLabel: string = 'Clear';\n /** Show a visible focus ring on initial entry after keyboard activation. */\n @Prop() initialFocusVisible: boolean = false;\n\n @State() private shouldRender = false;\n @State() private closing = false;\n @State() private positionReady = false;\n @State() private pos: { x: number; y: number } = { x: 0, y: 0 };\n @State() private focusRingVisible = false;\n @State() private activeOptionIndex = 0;\n\n /** Requests a controlled value replacement without closing the popup. */\n @Event() dsChange!: EventEmitter<FilterMenuChangeDetail>;\n /** Requests that the consumer clear every filter value. */\n @Event() dsClear!: EventEmitter<void>;\n /** Requests a controlled active-category replacement. */\n @Event() dsActiveFilterChange!: EventEmitter<string>;\n /** Requests that the controlled popup close. */\n @Event() dsClose!: EventEmitter<void>;\n /** Emitted whenever internal select-trigger activation changes popup visibility. */\n @Event() dsOpenChange!: EventEmitter<boolean>;\n /** Emitted after exit motion and rendered popup removal complete. */\n @Event() dsAfterClose!: EventEmitter<void>;\n\n private readonly generatedId = `ds-filter-menu-${++filterMenuId}`;\n private outsideHandler: ((event: MouseEvent) => void) | null = null;\n private triggerElement: HTMLButtonElement | null = null;\n private pendingInitialFocusVisible = false;\n private closeTimer: ReturnType<typeof setTimeout> | null = null;\n private closingSnapshot: {\n filters: FilterMenuFilter[];\n values: FilterMenuValues;\n activeFilterId: string | undefined;\n } | null = null;\n\n private readonly position = new AnchoredPositionController({\n getAnchor: () => this.resolvedAnchor,\n getPopup: () => this.el.querySelector<HTMLElement>('.filter-menu-popup'),\n measure: (anchor, popup) => {\n if (!this.open) return null;\n const anchorRect = anchor.getBoundingClientRect();\n const sectionInsetPx = resolveCssLengthPx(\n TOKEN_DEFAULTS.space050,\n TOKEN_DEFAULTS.space050\n );\n return {\n anchorRect,\n popupWidth: popup.offsetWidth || this.popupFallbackWidth,\n popupHeight: popup.offsetHeight || this.popupFallbackHeight,\n side: this.side,\n align: this.align,\n sideOffsetPx: resolveCssLengthPx(this.sideOffset, TOKEN_DEFAULTS.space050),\n alignOffsetPx: resolveChoicePopupAlignOffset({\n align: this.align,\n alignOffsetPx: resolveCssLengthPx(this.alignOffset, 0),\n sectionInsetPx,\n }),\n viewportPadPx: this.viewportPad,\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 observeResize: true,\n topLayer: true,\n });\n\n componentDidLoad() {\n if (this.open) this.onOpenChange(true);\n }\n\n disconnectedCallback() {\n this.position.unobserve();\n this.teardownListeners();\n }\n\n @Watch('open')\n onOpenChange(isOpen: boolean) {\n if (isOpen) {\n this.teardownListeners();\n this.closingSnapshot = null;\n this.shouldRender = true;\n this.closing = false;\n this.positionReady = false;\n this.focusRingVisible = this.pendingInitialFocusVisible || this.initialFocusVisible;\n this.setupListeners();\n this.position.schedule(() => this.focusInitialCategory());\n return;\n }\n\n if (!this.shouldRender) return;\n this.position.cancel();\n this.captureClosingSnapshot();\n this.closing = true;\n this.teardownListeners();\n const duration = resolveMotionTimeMs(\n TOKEN_DEFAULTS.motionShort2,\n TOKEN_DEFAULTS.animationDurationShort3\n );\n if (duration <= 0) {\n this.finishClose();\n return;\n }\n this.closeTimer = setTimeout(() => this.finishClose(), duration);\n }\n\n @Watch('anchor')\n @Watch('anchorId')\n @Watch('side')\n @Watch('align')\n @Watch('sideOffset')\n @Watch('alignOffset')\n @Watch('menuWidth')\n onPositionInputChange() {\n if (this.open) this.position.schedule();\n }\n\n @Listen('keydown')\n handleKeyDown(event: KeyboardEvent) {\n if (!this.shouldRender || this.closing) return;\n if (event.key === 'Escape') {\n event.preventDefault();\n this.close();\n return;\n }\n if (event.key !== 'Tab' || !this.tabLeavesPopup(event)) return;\n event.preventDefault();\n this.close(false);\n this.moveFocusAfterTab(event.shiftKey);\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 this.triggerElement;\n }\n\n private get usesInternalTrigger(): boolean {\n return !this.anchor && !this.anchorId;\n }\n\n private get anchorFocusTarget(): HTMLElement | null {\n const anchor = this.resolvedAnchor;\n if (!anchor) return null;\n if (anchor.matches(FILTER_MENU_FOCUSABLE_SELECTOR)) return anchor;\n return anchor.querySelector<HTMLElement>(FILTER_MENU_FOCUSABLE_SELECTOR) ?? anchor;\n }\n\n private get viewportPad(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.space050, TOKEN_DEFAULTS.space050);\n }\n\n private get popupFallbackWidth(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.menuWidthLg, TOKEN_DEFAULTS.menuWidthLg);\n }\n\n private get popupFallbackHeight(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.menuFallbackHeight, TOKEN_DEFAULTS.menuFallbackHeight);\n }\n\n private setupListeners() {\n this.outsideHandler = event => {\n const target = event.target as Node;\n const popup = this.el.querySelector('.filter-menu-popup');\n if (popup?.contains(target) || this.resolvedAnchor?.contains(target)) return;\n this.close();\n };\n document.addEventListener('mousedown', this.outsideHandler, true);\n this.position.observe();\n }\n\n private teardownListeners() {\n this.position.unobserve();\n if (this.outsideHandler) {\n document.removeEventListener('mousedown', this.outsideHandler, true);\n this.outsideHandler = null;\n }\n if (this.closeTimer) {\n clearTimeout(this.closeTimer);\n this.closeTimer = null;\n }\n }\n\n private close(restoreFocus = true) {\n this.captureClosingSnapshot();\n if (restoreFocus) this.focusAnchor();\n this.dsOpenChange.emit(false);\n this.dsClose.emit();\n this.open = false;\n this.onOpenChange(false);\n }\n\n private toggleFromTrigger(event: MouseEvent) {\n if (this.open) {\n this.close();\n return;\n }\n this.pendingInitialFocusVisible = event.detail === 0;\n this.open = true;\n this.dsOpenChange.emit(true);\n this.onOpenChange(true);\n }\n\n private handleTriggerKeyDown(event: KeyboardEvent) {\n if (event.key !== 'ArrowDown' || this.open) return;\n event.preventDefault();\n this.pendingInitialFocusVisible = true;\n this.open = true;\n this.dsOpenChange.emit(true);\n this.onOpenChange(true);\n }\n\n private finishClose() {\n const popup = this.el.querySelector<HTMLElement>('.filter-menu-popup');\n if (popup?.matches(':popover-open')) popup.hidePopover();\n this.shouldRender = false;\n this.closing = false;\n this.closingSnapshot = null;\n this.closeTimer = null;\n this.dsAfterClose.emit();\n }\n\n private captureClosingSnapshot() {\n if (this.closingSnapshot) return;\n this.closingSnapshot = {\n filters: this.filters.map(filter => ({\n ...filter,\n options: filter.options?.map(option => ({ ...option })),\n })),\n values: Object.fromEntries(\n Object.entries(this.values).map(([key, value]) => [\n key,\n Array.isArray(value) ? [...value] : value,\n ])\n ),\n activeFilterId: this.activeFilterId,\n };\n }\n\n private focusAnchor() {\n const anchor = this.resolvedAnchor as\n (HTMLElement & { setFocus?: () => Promise<void> | void }) | null;\n if (anchor?.setFocus) {\n void anchor.setFocus();\n return;\n }\n this.anchorFocusTarget?.focus();\n }\n\n private focusInitialCategory() {\n requestAnimationFrame(() => {\n this.el.querySelector<HTMLElement>('.filter-menu__category[tabindex=\"0\"]')?.focus();\n this.pendingInitialFocusVisible = false;\n });\n }\n\n private tabLeavesPopup(event: KeyboardEvent): boolean {\n const popup = this.el.querySelector<HTMLElement>('.filter-menu-popup');\n if (!popup) return true;\n const focusables = Array.from(\n popup.querySelectorAll<HTMLElement>(FILTER_MENU_FOCUSABLE_SELECTOR)\n ).filter(element => element.getClientRects().length > 0);\n const index = focusables.indexOf(event.target as HTMLElement);\n if (index < 0) return true;\n return event.shiftKey ? index === 0 : index === focusables.length - 1;\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 const candidates = Array.from(\n document.querySelectorAll<HTMLElement>(FILTER_MENU_FOCUSABLE_SELECTOR)\n ).filter(\n element =>\n !element.closest('.filter-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 selectedFilter(filters: FilterMenuFilter[], activeFilterId: string | undefined) {\n return filters.find(filter => filter.id === activeFilterId) ?? filters[0];\n }\n\n private selectedCount(filter: FilterMenuFilter, values: FilterMenuValues): number {\n const value = values[filter.id];\n if (filter.kind === 'multiple') return Array.isArray(value) ? value.length : 0;\n if (filter.kind === 'boolean') return value === true ? 1 : 0;\n return typeof value === 'string' && value.length > 0 ? 1 : 0;\n }\n\n private totalSelected(filters: FilterMenuFilter[], values: FilterMenuValues): number {\n return filters.reduce((total, filter) => total + this.selectedCount(filter, values), 0);\n }\n\n private activeFilterCount(filters: FilterMenuFilter[], values: FilterMenuValues): number {\n return filters.filter(filter => this.selectedCount(filter, values) > 0).length;\n }\n\n private selectCategory(filterId: string, focus = false) {\n this.activeOptionIndex = 0;\n this.dsActiveFilterChange.emit(filterId);\n if (!focus) return;\n requestAnimationFrame(() => {\n this.el.querySelector<HTMLElement>(`[data-filter-category=\"${filterId}\"]`)?.focus();\n });\n }\n\n private handleCategoryKeyDown(\n event: KeyboardEvent,\n filters: FilterMenuFilter[],\n filterId: string\n ) {\n const currentIndex = filters.findIndex(filter => filter.id === filterId);\n let nextIndex: number;\n if (event.key === 'ArrowDown') nextIndex = (currentIndex + 1) % filters.length;\n else if (event.key === 'ArrowUp')\n nextIndex = (currentIndex - 1 + filters.length) % filters.length;\n else if (event.key === 'Home') nextIndex = 0;\n else if (event.key === 'End') nextIndex = filters.length - 1;\n else if (event.key === 'ArrowRight') {\n event.preventDefault();\n requestAnimationFrame(() => {\n this.el\n .querySelector<HTMLElement>(\n '.filter-menu__options [role=\"option\"], .filter-menu__options input'\n )\n ?.focus();\n });\n return;\n } else return;\n\n event.preventDefault();\n this.focusRingVisible = true;\n this.selectCategory(filters[nextIndex].id, true);\n }\n\n private handlePanelKeyDown(event: KeyboardEvent, filterId: string) {\n if (event.key !== 'ArrowLeft') return;\n event.preventDefault();\n this.el.querySelector<HTMLElement>(`[data-filter-category=\"${filterId}\"]`)?.focus();\n }\n\n private handleOptionKeyDown(\n event: KeyboardEvent,\n filter: FilterMenuFilter,\n option: FilterMenuOption,\n index: number,\n values: FilterMenuValues\n ) {\n const options = filter.options ?? [];\n if (event.key === 'ArrowLeft') {\n this.handlePanelKeyDown(event, filter.id);\n return;\n }\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault();\n this.selectOption(filter, option, values);\n return;\n }\n if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;\n event.preventDefault();\n const enabled = options\n .map((candidate, optionIndex) => ({ candidate, optionIndex }))\n .filter(({ candidate }) => !candidate.isInactive)\n .map(({ optionIndex }) => optionIndex);\n if (!enabled.length) return;\n const current = enabled.indexOf(index);\n let next: number;\n if (event.key === 'Home') next = enabled[0];\n else if (event.key === 'End') next = enabled[enabled.length - 1];\n else {\n const direction = event.key === 'ArrowDown' ? 1 : -1;\n const start = current >= 0 ? current : direction === 1 ? -1 : 0;\n next = enabled[(start + direction + enabled.length) % enabled.length];\n }\n this.activeOptionIndex = next;\n this.focusRingVisible = true;\n requestAnimationFrame(() => {\n this.el.querySelector<HTMLElement>(`#${this.generatedId}-${filter.id}-option-${next}`)?.focus();\n });\n }\n\n private toggleMultiple(\n filter: FilterMenuFilter,\n option: FilterMenuOption,\n checked: boolean,\n values: FilterMenuValues\n ) {\n if (option.isInactive) return;\n const current = Array.isArray(values[filter.id]) ? (values[filter.id] as string[]) : [];\n const selected = new Set(current);\n if (checked) selected.add(option.value);\n else selected.delete(option.value);\n const ordered = (filter.options ?? [])\n .map(candidate => candidate.value)\n .filter(value => selected.has(value));\n this.dsChange.emit({ filterId: filter.id, value: ordered });\n }\n\n private selectOption(\n filter: FilterMenuFilter,\n option: FilterMenuOption,\n values: FilterMenuValues\n ) {\n if (option.isInactive) return;\n if (filter.kind === 'single') {\n this.dsChange.emit({ filterId: filter.id, value: option.value });\n return;\n }\n const selected = Array.isArray(values[filter.id])\n ? (values[filter.id] as string[]).includes(option.value)\n : false;\n this.toggleMultiple(filter, option, !selected, values);\n }\n\n private renderOptions(filter: FilterMenuFilter, values: FilterMenuValues) {\n const value = values[filter.id];\n if (filter.kind === 'multiple' || filter.kind === 'single') {\n const selected = Array.isArray(value) ? value : [];\n const options = filter.options ?? [];\n const usesSubtext = choiceListUsesSubtext(\n options.map(option => ({\n label: option.label,\n value: option.value,\n subtext: option.description,\n }))\n );\n return options.map((option, index) => {\n const isSelected =\n filter.kind === 'multiple'\n ? selected.includes(option.value)\n : value === option.value;\n return (\n <ChoiceOptionRow\n size=\"md\"\n id={`${this.generatedId}-${filter.id}-option-${index}`}\n option={{\n label: option.label,\n value: option.value,\n subtext: option.description,\n isInactive: option.isInactive,\n }}\n selected={isSelected}\n active={index === this.activeOptionIndex}\n focusRingVisible={this.focusRingVisible}\n usesSubtext={usesSubtext}\n tabIndex={index === this.activeOptionIndex ? 0 : -1}\n leading={\n filter.kind === 'multiple' ? (\n <span\n class=\"ds-choice-item__icon ds-control-icon-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n <ds-checkbox label=\"\" size=\"md\" checked={isSelected} presentation />\n </span>\n ) : undefined\n }\n onFocus={() => {\n this.activeOptionIndex = index;\n }}\n onKeyDown={(event: KeyboardEvent) =>\n this.handleOptionKeyDown(event, filter, option, index, values)\n }\n onHover={() => {\n this.focusRingVisible = false;\n this.activeOptionIndex = index;\n }}\n onSelect={() => this.selectOption(filter, option, values)}\n />\n );\n });\n }\n\n if (filter.kind === 'boolean') {\n const option: FilterMenuOption = {\n label: filter.fieldLabel ?? filter.label,\n value: 'true',\n description: filter.description,\n };\n return (\n <ChoiceOptionRow\n size=\"md\"\n id={`${this.generatedId}-${filter.id}-option-0`}\n option={{\n label: option.label,\n value: option.value,\n subtext: option.description,\n }}\n selected={value === true}\n active\n focusRingVisible={this.focusRingVisible}\n usesSubtext={Boolean(option.description)}\n tabIndex={0}\n leading={\n <span\n class=\"ds-choice-item__icon ds-control-icon-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n <ds-checkbox label=\"\" size=\"md\" checked={value === true} presentation />\n </span>\n }\n onKeyDown={(event: KeyboardEvent) => {\n if (event.key === 'ArrowLeft') this.handlePanelKeyDown(event, filter.id);\n else if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault();\n this.dsChange.emit({ filterId: filter.id, value: value !== true });\n }\n }}\n onHover={() => {\n this.focusRingVisible = false;\n }}\n onSelect={() => this.dsChange.emit({ filterId: filter.id, value: value !== true })}\n />\n );\n }\n\n const dateValue = typeof value === 'string' ? value : '';\n const inputId = `${this.generatedId}-${filter.id}-input`;\n return (\n <div class=\"filter-menu__date-field\">\n <label htmlFor={inputId}>\n <ds-text as=\"span\" variant=\"text-body-small\" emphasis color=\"primary\">\n {filter.fieldLabel ?? 'Event date'}\n </ds-text>\n </label>\n <div class=\"filter-menu__date-control ds-control-frame ds-control--md\">\n <input\n id={inputId}\n class=\"filter-menu__date-input\"\n type=\"date\"\n value={dateValue}\n onInput={(event: Event) =>\n this.dsChange.emit({\n filterId: filter.id,\n value: (event.target as HTMLInputElement).value,\n })\n }\n />\n {dateValue ? (\n <ds-button-unfilled\n variant=\"icon\"\n icon=\"CrossCircle\"\n size=\"sm\"\n isInset\n hasBorder={false}\n rounded\n ariaLabel={`${this.clearLabel} ${filter.fieldLabel ?? filter.label}`}\n onDsClick={(event: CustomEvent<MouseEvent>) => {\n event.stopPropagation();\n this.dsChange.emit({ filterId: filter.id, value: '' });\n }}\n />\n ) : null}\n </div>\n </div>\n );\n }\n\n render() {\n const state =\n this.closing && this.closingSnapshot\n ? this.closingSnapshot\n : {\n filters: this.filters,\n values: this.values,\n activeFilterId: this.activeFilterId,\n };\n const activeFilter = this.selectedFilter(state.filters, state.activeFilterId);\n const totalSelected = this.totalSelected(state.filters, state.values);\n const activeFilterCount = this.activeFilterCount(state.filters, state.values);\n const hasActiveFilters = activeFilterCount > 0;\n const label = `${this.triggerLabel}${hasActiveFilters ? ` · ${activeFilterCount}` : ''}`;\n const textVariant = CONTROL_TEXT_VARIANT[this.size];\n const iconSize = ICON_SIZE[this.size];\n const popupId = `${this.generatedId}-popup`;\n const popupStyle: Record<string, string> = {\n position: 'fixed',\n left: '0',\n top: '0',\n width: this.menuWidth,\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 return (\n <Host\n class={{\n 'filter-menu-host': true,\n 'filter-menu-host--external-trigger': !this.usesInternalTrigger,\n 'ds-select-trigger-host': this.usesInternalTrigger,\n [`ds-control--${this.size}`]: true,\n ...controlWidthClass(this.width),\n }}\n >\n {this.usesInternalTrigger ? (\n <button\n ref={element => {\n this.triggerElement = (element as HTMLButtonElement) ?? null;\n }}\n id={this.inputId ?? `${this.generatedId}-trigger`}\n type=\"button\"\n class={{\n trigger: true,\n 'ds-control-frame': true,\n 'ds-focus-ring-inset': true,\n 'ds-interaction-fill': true,\n 'ds-interaction-fill--selected': this.activeFill && hasActiveFilters,\n 'trigger--expanded': this.open || this.closing,\n 'trigger--bordered': this.hasBorder,\n 'trigger--has-value': hasActiveFilters,\n [`ds-control--${this.size}`]: true,\n }}\n role=\"combobox\"\n aria-haspopup=\"dialog\"\n aria-expanded={String(this.open)}\n aria-controls={this.open ? popupId : undefined}\n aria-label={this.ariaLabel ?? this.menuLabel}\n onClick={(event: MouseEvent) => this.toggleFromTrigger(event)}\n onKeyDown={(event: KeyboardEvent) => this.handleTriggerKeyDown(event)}\n >\n {this.icon ? (\n <span\n class=\"trigger__prefix ds-control-icon-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n <ds-icon name={this.icon} size={iconSize} color=\"inherit\" />\n </span>\n ) : null}\n <ds-text\n class=\"trigger__label-box trigger__label trigger__label-content ds-control-label-box ds-interaction-fill__content\"\n as=\"span\"\n variant={textVariant}\n color=\"inherit\"\n lineTruncation={1}\n >\n {label}\n </ds-text>\n <span\n class=\"trigger__chevron ds-control-icon-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n <ds-icon name=\"ChevronDown\" size={iconSize} color=\"inherit\" />\n </span>\n </button>\n ) : null}\n\n {this.shouldRender ? (\n <div\n id={popupId}\n popover=\"manual\"\n class={{\n 'filter-menu-popup': true,\n 'ds-choice-popup': true,\n 'ds-choice-popup--closing': this.closing,\n }}\n style={popupStyle}\n role=\"dialog\"\n aria-label={this.menuLabel}\n >\n {activeFilter ? (\n <div class=\"filter-menu\">\n <div class=\"filter-menu__body\">\n <div\n class=\"filter-menu__categories ds-choice-list ds-chrome-column ds-chrome-space--sm\"\n role=\"tablist\"\n aria-label={this.categoriesLabel}\n aria-orientation=\"vertical\"\n >\n {state.filters.map(filter => {\n const active = filter.id === activeFilter.id;\n const count = this.selectedCount(filter, state.values);\n return (\n <div\n key={filter.id}\n class={{\n 'filter-menu__category-section': true,\n 'ds-choice-section--divided': Boolean(filter.divider),\n }}\n >\n <button\n id={`${this.generatedId}-${filter.id}-tab`}\n type=\"button\"\n role=\"tab\"\n data-filter-category={filter.id}\n class={{\n 'filter-menu__category': true,\n 'filter-menu__category--active': active,\n 'ds-choice-item': true,\n 'ds-control-frame': true,\n 'ds-control--md': true,\n 'ds-focus-ring-inset': true,\n 'ds-focus-ring--visible': active && this.focusRingVisible,\n 'ds-interaction-fill': true,\n 'ds-interaction-fill--selected': active,\n }}\n aria-selected={String(active)}\n aria-controls={`${this.generatedId}-panel`}\n tabIndex={active ? 0 : -1}\n onMouseDown={() => {\n this.focusRingVisible = false;\n }}\n onClick={() => this.selectCategory(filter.id)}\n onKeyDown={(event: KeyboardEvent) =>\n this.handleCategoryKeyDown(event, state.filters, filter.id)\n }\n >\n <ds-text\n class=\"ds-choice-item__content ds-choice-item__label ds-control-label-box ds-interaction-fill__content\"\n as=\"span\"\n variant=\"text-body-medium\"\n color={active ? 'primary' : 'secondary'}\n >\n {filter.label}\n </ds-text>\n {count > 0 ? (\n <ds-tag\n class=\"ds-choice-item__tag ds-interaction-fill__content\"\n label={String(count)}\n size=\"sm\"\n intent=\"brand\"\n contrast=\"bold\"\n isInset\n rounded\n />\n ) : null}\n </button>\n </div>\n );\n })}\n </div>\n\n <div\n id={`${this.generatedId}-panel`}\n class=\"filter-menu__options\"\n role=\"tabpanel\"\n aria-labelledby={`${this.generatedId}-${activeFilter.id}-tab`}\n >\n <div\n key={activeFilter.id}\n class=\"filter-menu__option-list ds-choice-list ds-chrome-column ds-chrome-space--sm\"\n role=\"listbox\"\n aria-label={activeFilter.label}\n aria-multiselectable={\n activeFilter.kind === 'multiple' ? 'true' : undefined\n }\n >\n {this.renderOptions(activeFilter, state.values)}\n </div>\n </div>\n </div>\n\n {totalSelected > 0 ? (\n <div class=\"filter-menu__footer ds-choice-footer\">\n <div class=\"ds-choice-footer__content ds-control--md\">\n <ds-text\n class=\"ds-choice-footer__summary\"\n as=\"span\"\n variant=\"text-body-medium\"\n color=\"secondary\"\n aria-live=\"polite\"\n >\n {totalSelected} selected\n </ds-text>\n <button\n class=\"filter-menu__clear ds-choice-footer__clear ds-text-action\"\n type=\"button\"\n onClick={() => this.dsClear.emit()}\n >\n <ds-text as=\"span\" variant=\"text-body-medium\" color=\"inherit\">\n {this.clearLabel}\n </ds-text>\n </button>\n </div>\n </div>\n ) : null}\n </div>\n ) : null}\n </div>\n ) : null}\n </Host>\n );\n }\n}\n",
640
641
  "type": "registry:ui"
641
642
  }
642
643
  ]