@ds-mo/ui 14.15.1 → 14.16.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 (243) hide show
  1. package/dist/.package-ready.json +1 -1
  2. package/dist/agent-patterns.json +3 -3
  3. package/dist/agent.json +159 -22
  4. package/dist/angular/ds-button-unfilled.d.ts +1 -1
  5. package/dist/angular/ds-button-unfilled.js +3 -3
  6. package/dist/angular/ds-filter-menu.d.ts +1 -1
  7. package/dist/angular/ds-filter-menu.js +3 -3
  8. package/dist/angular/ds-select.d.ts +1 -1
  9. package/dist/angular/ds-select.js +3 -3
  10. package/dist/angular/ds-table-sort.d.ts +16 -0
  11. package/dist/angular/ds-table-sort.js +43 -0
  12. package/dist/angular/index.d.ts +1 -1
  13. package/dist/angular/index.js +1 -0
  14. package/dist/angular/proxies.d.ts +1 -0
  15. package/dist/angular/proxies.js +1 -0
  16. package/dist/components/ds-agent-activity.js +1 -1
  17. package/dist/components/ds-agent-questionnaire.js +1 -1
  18. package/dist/components/ds-agent-response.js +1 -1
  19. package/dist/components/ds-agent-source-list.js +1 -1
  20. package/dist/components/ds-agent-tool-call.js +1 -1
  21. package/dist/components/ds-attachment-list.js +1 -1
  22. package/dist/components/ds-badge.js +1 -1
  23. package/dist/components/ds-banner.js +1 -1
  24. package/dist/components/ds-banner.js.map +1 -1
  25. package/dist/components/ds-bar-action.js +1 -1
  26. package/dist/components/ds-bar-action.js.map +1 -1
  27. package/dist/components/ds-bar-nav.js +1 -1
  28. package/dist/components/ds-bar-title.js +1 -1
  29. package/dist/components/ds-bar-workflow.js +1 -1
  30. package/dist/components/ds-bar-workflow.js.map +1 -1
  31. package/dist/components/ds-breadcrumb.js +1 -1
  32. package/dist/components/ds-button-filled.js +1 -1
  33. package/dist/components/ds-button-unfilled.js +1 -1
  34. package/dist/components/ds-card-chart.js +1 -1
  35. package/dist/components/ds-card-chart.js.map +1 -1
  36. package/dist/components/ds-card-overview.js +1 -1
  37. package/dist/components/ds-card-overview.js.map +1 -1
  38. package/dist/components/ds-card-setting.js +1 -1
  39. package/dist/components/ds-card-setting.js.map +1 -1
  40. package/dist/components/ds-chart-legend.js +1 -1
  41. package/dist/components/ds-chart-legend.js.map +1 -1
  42. package/dist/components/ds-chart.js +1 -1
  43. package/dist/components/ds-chart.js.map +1 -1
  44. package/dist/components/ds-checkbox.js +1 -1
  45. package/dist/components/ds-chip.js +1 -1
  46. package/dist/components/ds-chip.js.map +1 -1
  47. package/dist/components/ds-code-block.js +1 -1
  48. package/dist/components/ds-conversation-list-item.js +1 -1
  49. package/dist/components/ds-conversation-list-item.js.map +1 -1
  50. package/dist/components/ds-conversation-list-section.js +1 -1
  51. package/dist/components/ds-empty-state.js +1 -1
  52. package/dist/components/ds-field.js +1 -1
  53. package/dist/components/ds-filter-menu.js +1 -1
  54. package/dist/components/ds-filter-menu.js.map +1 -1
  55. package/dist/components/ds-input.js +1 -1
  56. package/dist/components/ds-loader.js +1 -1
  57. package/dist/components/ds-markdown.js +1 -1
  58. package/dist/components/ds-menu.js +1 -1
  59. package/dist/components/ds-message-actions.js +1 -1
  60. package/dist/components/ds-message-actions.js.map +1 -1
  61. package/dist/components/ds-message-composer.js +1 -1
  62. package/dist/components/ds-message-composer.js.map +1 -1
  63. package/dist/components/ds-message-scroller.js +1 -1
  64. package/dist/components/ds-message-scroller.js.map +1 -1
  65. package/dist/components/ds-message.js +1 -1
  66. package/dist/components/ds-mobile-bar-nav.js +1 -1
  67. package/dist/components/ds-mobile-header.js +1 -1
  68. package/dist/components/ds-mobile-section-switcher.js +1 -1
  69. package/dist/components/ds-mobile-sheet-nav.js +1 -1
  70. package/dist/components/ds-modal.js +1 -1
  71. package/dist/components/ds-pagination.js +1 -1
  72. package/dist/components/ds-panel-nav.js +1 -1
  73. package/dist/components/ds-panel-sub-nav.js +1 -1
  74. package/dist/components/ds-panel-sub-nav.js.map +1 -1
  75. package/dist/components/ds-panel-tool-header.js +1 -1
  76. package/dist/components/ds-panel-tool-search.js +1 -1
  77. package/dist/components/ds-panel-tool-search.js.map +1 -1
  78. package/dist/components/ds-panel-tools.js +1 -1
  79. package/dist/components/ds-paper-texture.js +1 -1
  80. package/dist/components/ds-radio.js +1 -1
  81. package/dist/components/ds-scroll-overlay.js +1 -1
  82. package/dist/components/ds-scroll-overlay.js.map +1 -1
  83. package/dist/components/ds-select.js +1 -1
  84. package/dist/components/ds-shell-app.js +1 -1
  85. package/dist/components/ds-shell-app.js.map +1 -1
  86. package/dist/components/ds-shell-page.js +1 -1
  87. package/dist/components/ds-shell-tools.js +1 -1
  88. package/dist/components/ds-skeleton.js +1 -1
  89. package/dist/components/ds-slider.js +1 -1
  90. package/dist/components/ds-slider.js.map +1 -1
  91. package/dist/components/ds-swatch-picker.js +1 -1
  92. package/dist/components/ds-switch.js +1 -1
  93. package/dist/components/ds-tab-group.js +1 -1
  94. package/dist/components/ds-table-saved-views.js +1 -1
  95. package/dist/components/ds-table-saved-views.js.map +1 -1
  96. package/dist/components/ds-table-sort.d.ts +11 -0
  97. package/dist/components/ds-table-sort.js +2 -0
  98. package/dist/components/ds-table-sort.js.map +1 -0
  99. package/dist/components/ds-table-toolbar.js +1 -1
  100. package/dist/components/ds-table-toolbar.js.map +1 -1
  101. package/dist/components/ds-table.js +1 -1
  102. package/dist/components/ds-table.js.map +1 -1
  103. package/dist/components/ds-tag.js +1 -1
  104. package/dist/components/ds-text.js +1 -1
  105. package/dist/components/ds-toast.js +1 -1
  106. package/dist/components/ds-toast.js.map +1 -1
  107. package/dist/components/ds-tooltip-chart.js +1 -1
  108. package/dist/components/ds-tooltip.js +1 -1
  109. package/dist/components/ds-typing-indicator.js +1 -1
  110. package/dist/components/index.js +1 -1
  111. package/dist/components/index.js.map +1 -1
  112. package/dist/components/{p-CjgnFGd6.js → p-0IbKxhqM.js} +2 -2
  113. package/dist/components/{p-CjgnFGd6.js.map → p-0IbKxhqM.js.map} +1 -1
  114. package/dist/components/p-23u3lIr1.js +2 -0
  115. package/dist/components/{p-DLkNcCY0.js.map → p-23u3lIr1.js.map} +1 -1
  116. package/dist/components/{p-DfyfrfHL.js → p-3dYwA4i_.js} +2 -2
  117. package/dist/components/{p-DfyfrfHL.js.map → p-3dYwA4i_.js.map} +1 -1
  118. package/dist/components/{p-C1q-tq3U.js → p-7tZx4ukz.js} +2 -2
  119. package/dist/components/{p-C5TrNd31.js → p-B0PEg5RF.js} +2 -2
  120. package/dist/components/{p-C5TrNd31.js.map → p-B0PEg5RF.js.map} +1 -1
  121. package/dist/components/{p-DZFilC--.js → p-BGXqFq2C.js} +2 -2
  122. package/dist/components/{p-Rfpd5mWO.js → p-BHDkJmrC.js} +2 -2
  123. package/dist/components/{p-Rfpd5mWO.js.map → p-BHDkJmrC.js.map} +1 -1
  124. package/dist/components/{p-BXPQmp7q.js → p-BK-JjdoN.js} +2 -2
  125. package/dist/components/p-BUhShHfb.js +2 -0
  126. package/dist/components/{p-URdgCwEs.js.map → p-BUhShHfb.js.map} +1 -1
  127. package/dist/components/{p-CKY989vF.js → p-BUrbVZJU.js} +2 -2
  128. package/dist/components/{p-CKY989vF.js.map → p-BUrbVZJU.js.map} +1 -1
  129. package/dist/components/{p-V2ywPfsR.js → p-BVlfQ8xZ.js} +2 -2
  130. package/dist/components/{p-V2ywPfsR.js.map → p-BVlfQ8xZ.js.map} +1 -1
  131. package/dist/components/p-BdnQv7Zl.js +2 -0
  132. package/dist/components/p-BdnQv7Zl.js.map +1 -0
  133. package/dist/components/{p-Btxwp4lJ.js → p-BhYWdbEp.js} +2 -2
  134. package/dist/components/{p-Btxwp4lJ.js.map → p-BhYWdbEp.js.map} +1 -1
  135. package/dist/components/{p-B80NfEqc.js → p-C0X2zKbB.js} +2 -2
  136. package/dist/components/{p-Cyw9odJX.js → p-C4p2i59V.js} +2 -2
  137. package/dist/components/{p-Cyw9odJX.js.map → p-C4p2i59V.js.map} +1 -1
  138. package/dist/components/p-C91izpHV.js +2 -0
  139. package/dist/components/{p-CnWC0QvG.js.map → p-C91izpHV.js.map} +1 -1
  140. package/dist/components/p-COVcyUAA.js +2 -0
  141. package/dist/components/p-COVcyUAA.js.map +1 -0
  142. package/dist/components/{p-D1Nqlrrl.js → p-C_TOgX82.js} +2 -2
  143. package/dist/components/{p-D1Nqlrrl.js.map → p-C_TOgX82.js.map} +1 -1
  144. package/dist/components/{p-Ct-m0I6U.js → p-CaAZ-D08.js} +2 -2
  145. package/dist/components/p-Cr2VT5zv.js +2 -0
  146. package/dist/components/{p-M9DPfXbP.js.map → p-Cr2VT5zv.js.map} +1 -1
  147. package/dist/components/{p-Dsn1RCS5.js → p-Ct2GVdSI.js} +2 -2
  148. package/dist/components/{p-Dsn1RCS5.js.map → p-Ct2GVdSI.js.map} +1 -1
  149. package/dist/components/p-CwkcFMMe.js +2 -0
  150. package/dist/components/{p-CWDmHPJC.js.map → p-CwkcFMMe.js.map} +1 -1
  151. package/dist/components/p-CxBE3l74.js +2 -0
  152. package/dist/components/{p-CKJzHclc.js.map → p-CxBE3l74.js.map} +1 -1
  153. package/dist/components/{p-PzI11fs8.js → p-CzcWZiH-.js} +2 -2
  154. package/dist/components/{p-PzI11fs8.js.map → p-CzcWZiH-.js.map} +1 -1
  155. package/dist/components/{p-BagXfn1G.js → p-D1YhYulL.js} +2 -2
  156. package/dist/components/{p-BzObtSlo.js → p-D2z2DgtX.js} +2 -2
  157. package/dist/components/{p-BzObtSlo.js.map → p-D2z2DgtX.js.map} +1 -1
  158. package/dist/components/{p-DcPVSNDr.js → p-D8Qd6gkf.js} +2 -2
  159. package/dist/components/{p-DcPVSNDr.js.map → p-D8Qd6gkf.js.map} +1 -1
  160. package/dist/components/p-DAV32g35.js +2 -0
  161. package/dist/components/p-DAV32g35.js.map +1 -0
  162. package/dist/components/{p-BHjw0x3L.js → p-DAqSoyd4.js} +2 -2
  163. package/dist/components/{p-BHjw0x3L.js.map → p-DAqSoyd4.js.map} +1 -1
  164. package/dist/components/{p-Bf_D3crn.js → p-DNJTN_s0.js} +2 -2
  165. package/dist/components/{p-DL_5VENG.js → p-DQqOzDEq.js} +2 -2
  166. package/dist/components/{p-C5eosu67.js → p-DRBTr6TS.js} +2 -2
  167. package/dist/components/{p-C5eosu67.js.map → p-DRBTr6TS.js.map} +1 -1
  168. package/dist/components/{p-D-kWb8cF.js → p-DSIOINBK.js} +2 -2
  169. package/dist/components/{p-QdI17bl-.js → p-Dg9qiMDa.js} +2 -2
  170. package/dist/components/{p-DTMxwYQj.js → p-EWI6ccs-.js} +2 -2
  171. package/dist/components/p-JPTkZ3Ji.js +2 -0
  172. package/dist/components/p-JPTkZ3Ji.js.map +1 -0
  173. package/dist/components/p-MxptXwlW.js +2 -0
  174. package/dist/components/{p-C2n8A8NA.js.map → p-MxptXwlW.js.map} +1 -1
  175. package/dist/components/{p-B7Hf86NF.js → p-PD1Vfg0t.js} +2 -2
  176. package/dist/components/{p-B7Hf86NF.js.map → p-PD1Vfg0t.js.map} +1 -1
  177. package/dist/components/{p-BxqBiVbH.js → p-U8JIZzI2.js} +2 -2
  178. package/dist/components/{p-BxqBiVbH.js.map → p-U8JIZzI2.js.map} +1 -1
  179. package/dist/components/{p-Ck_VRQYi.js → p-WGZWqh-B.js} +2 -2
  180. package/dist/components/{p-Ck_VRQYi.js.map → p-WGZWqh-B.js.map} +1 -1
  181. package/dist/components/{p-vWK5faWZ.js → p-a6qP60zb.js} +2 -2
  182. package/dist/components/p-vzxD117T.js +2 -0
  183. package/dist/components/{p-BzLnSwG9.js.map → p-vzxD117T.js.map} +1 -1
  184. package/dist/components/{p-jMCRaxQW.js → p-y4jhIkRc.js} +2 -2
  185. package/dist/components/{p-jMCRaxQW.js.map → p-y4jhIkRc.js.map} +1 -1
  186. package/dist/docs/components.json +318 -10
  187. package/dist/mcp-data/registry/button-unfilled.json +26 -7
  188. package/dist/mcp-data/registry/filter-menu.json +25 -6
  189. package/dist/mcp-data/registry/loader.json +1 -1
  190. package/dist/mcp-data/registry/registry.json +161 -21
  191. package/dist/mcp-data/registry/select.json +22 -3
  192. package/dist/mcp-data/registry/table-saved-views.json +4 -4
  193. package/dist/mcp-data/registry/table-sort.json +229 -0
  194. package/dist/mcp-data/registry/table-toolbar.json +19 -12
  195. package/dist/mcp-data/registry/table.json +10 -8
  196. package/dist/react/components.d.ts +1 -0
  197. package/dist/react/components.js +1 -0
  198. package/dist/react/ds-table-sort.d.ts +12 -0
  199. package/dist/react/ds-table-sort.js +12 -0
  200. package/dist/types/components/ButtonUnfilled/ButtonUnfilled.d.ts +16 -1
  201. package/dist/types/components/FilterMenu/FilterMenu.d.ts +11 -0
  202. package/dist/types/components/Select/Select.d.ts +11 -0
  203. package/dist/types/components/Table/Table.d.ts +4 -0
  204. package/dist/types/components/Table/table-column-customizer.d.ts +2 -0
  205. package/dist/types/components/TableSort/TableSort.d.ts +24 -0
  206. package/dist/types/components/TableSort/table-sort-menu.d.ts +15 -0
  207. package/dist/types/components/TableToolbar/TableToolbar.d.ts +6 -0
  208. package/dist/types/components.d.ts +102 -6
  209. package/dist/types/utils/table-caption-compact.d.ts +6 -0
  210. package/dist/vue/components.d.ts +1 -0
  211. package/dist/vue/components.js +1 -0
  212. package/dist/vue/ds-button-unfilled.js +1 -0
  213. package/dist/vue/ds-filter-menu.js +1 -0
  214. package/dist/vue/ds-select.js +1 -0
  215. package/dist/vue/ds-table-sort.d.ts +3 -0
  216. package/dist/vue/ds-table-sort.js +13 -0
  217. package/package.json +1 -1
  218. package/dist/components/p-BzLnSwG9.js +0 -2
  219. package/dist/components/p-C2n8A8NA.js +0 -2
  220. package/dist/components/p-C7rzVotG.js +0 -2
  221. package/dist/components/p-C7rzVotG.js.map +0 -1
  222. package/dist/components/p-CKJzHclc.js +0 -2
  223. package/dist/components/p-CWDmHPJC.js +0 -2
  224. package/dist/components/p-Ca-I2UnB.js +0 -2
  225. package/dist/components/p-Ca-I2UnB.js.map +0 -1
  226. package/dist/components/p-CnWC0QvG.js +0 -2
  227. package/dist/components/p-CooNucm0.js +0 -2
  228. package/dist/components/p-CooNucm0.js.map +0 -1
  229. package/dist/components/p-DLkNcCY0.js +0 -2
  230. package/dist/components/p-M9DPfXbP.js +0 -2
  231. package/dist/components/p-URdgCwEs.js +0 -2
  232. /package/dist/components/{p-C1q-tq3U.js.map → p-7tZx4ukz.js.map} +0 -0
  233. /package/dist/components/{p-DZFilC--.js.map → p-BGXqFq2C.js.map} +0 -0
  234. /package/dist/components/{p-BXPQmp7q.js.map → p-BK-JjdoN.js.map} +0 -0
  235. /package/dist/components/{p-B80NfEqc.js.map → p-C0X2zKbB.js.map} +0 -0
  236. /package/dist/components/{p-Ct-m0I6U.js.map → p-CaAZ-D08.js.map} +0 -0
  237. /package/dist/components/{p-BagXfn1G.js.map → p-D1YhYulL.js.map} +0 -0
  238. /package/dist/components/{p-Bf_D3crn.js.map → p-DNJTN_s0.js.map} +0 -0
  239. /package/dist/components/{p-DL_5VENG.js.map → p-DQqOzDEq.js.map} +0 -0
  240. /package/dist/components/{p-D-kWb8cF.js.map → p-DSIOINBK.js.map} +0 -0
  241. /package/dist/components/{p-QdI17bl-.js.map → p-Dg9qiMDa.js.map} +0 -0
  242. /package/dist/components/{p-DTMxwYQj.js.map → p-EWI6ccs-.js.map} +0 -0
  243. /package/dist/components/{p-vWK5faWZ.js.map → p-a6qP60zb.js.map} +0 -0
@@ -71,7 +71,7 @@
71
71
  "Option labels and the selected trigger label stay on one line and truncate when constrained. Option subtext may wrap to at most two lines.",
72
72
  "Use an option action only for a contextual menu about that user-managed option. The fixed ellipsis action appears on hover, keyboard activity, focus, and touch; expanded keeps it visible while its controlled menu is open. Activating it emits dsOptionAction without selecting the option or closing the Select popup.",
73
73
  "Use option subtextActions for a compact set of immediate actions that belong to one option. They render beneath only that option's label using the standard supporting-text inset and rhythm, use action or negative semantic color, and emit dsOptionSubtextAction without selecting the option or closing the popup. Unlike descriptive subtext, they do not reserve a blank supporting line on neighboring options.",
74
- "The optional prefix icon and trailing indicator always use the parent surface's secondary foreground color, including when a selected value promotes the label to primary. The default down indicator communicates disclosure; up-down is reserved for compact value steppers such as pagination page size.",
74
+ "The optional prefix icon and trailing indicator always use the parent surface's secondary foreground color, including when a selected value promotes the label to primary. When collapseLabel compact icon-only chrome is active, a selected value promotes the prefix icon to primary instead. The default down indicator communicates disclosure; up-down is reserved for compact value steppers such as pagination page size.",
75
75
  "While the popup is open, the trigger holds the pressed interaction wash without changing its resting foreground or selected-value treatment.",
76
76
  "The shared popup search control follows the Select density, using the same text, icon, and internal spacing as a borderless trigger. Its container uses menu-item padding on every side and owns a 1px bottom divider that becomes a 1.5px bold-brand stroke while search has focus. Search placeholder text is secondary at rest and quaternary while focused because it becomes decorative once the editable field is active.",
77
77
  "When search contains text, the shared control shows a rounded icon-only ButtonUnfilled with the CrossCircle icon and no border, sized to remain inset within the current row density. It clears the local term, stays hidden for an empty term, and returns focus to search.",
@@ -87,6 +87,7 @@
87
87
  ],
88
88
  "responsiveBehavior": [
89
89
  "Use width fill for form fields and width hug for compact filter bars. Override those defaults only when the owning layout has a clear constraint.",
90
+ "When collapseLabel is set inside a named ds-table container narrower than 900px, the trigger omits its label and chevron and becomes icon-only. A selected value promotes the prefix icon to primary.",
90
91
  "The popup matches the trigger minimum width, may grow for content, and clamps to the viewport.",
91
92
  "Keep popupAlign at start for ordinary fields and filters. Use end when a control sits against a right layout edge and the popup should grow inward.",
92
93
  "Long option collections scroll inside the popup; virtualization and remote search are not part of this component."
@@ -178,6 +179,15 @@
178
179
  "mutable": false,
179
180
  "description": "Localized clear action label."
180
181
  },
182
+ "collapseLabel": {
183
+ "type": "boolean",
184
+ "resolvedType": "boolean",
185
+ "attribute": "collapse-label",
186
+ "default": "false",
187
+ "required": false,
188
+ "mutable": false,
189
+ "description": "Opt into table-caption icon-only chrome below 900px. The trigger omits its\nvisible label and chevron; keep an accessible name via aria-label."
190
+ },
181
191
  "disabled": {
182
192
  "type": "boolean",
183
193
  "resolvedType": "boolean",
@@ -560,6 +570,15 @@
560
570
  "mutable": false,
561
571
  "description": "Localized clear action label."
562
572
  },
573
+ "collapseLabel": {
574
+ "type": "boolean",
575
+ "resolvedType": "boolean",
576
+ "attribute": "collapse-label",
577
+ "default": "false",
578
+ "required": false,
579
+ "mutable": false,
580
+ "description": "Opt into table-caption icon-only chrome below 900px. The trigger omits its\nvisible label and chevron; keep an accessible name via aria-label."
581
+ },
563
582
  "disabled": {
564
583
  "type": "boolean",
565
584
  "resolvedType": "boolean",
@@ -933,12 +952,12 @@
933
952
  "files": [
934
953
  {
935
954
  "path": "src/wc/components/Select/Select.css",
936
- "content": "@import '../../utils/choice-list.css';\n@import '../../utils/control-parts.css';\n@import '../../utils/control-width.css';\n@import '../../utils/field-stack.css';\n@import '../../utils/search-control.css';\n@import '../../utils/select-trigger.css';\n@import '../../utils/typography.css';\n@import '../../styles/control-elevation.css';\n\n.wrapper--error {\n --ds-interaction-border-width: var(--dimension-stroke-width-018);\n --ds-interaction-border-color: var(--color-border-bold-negative);\n}\n\n.select-popup {\n color: var(--color-foreground-secondary);\n}\n\n.select-option[aria-selected='true'] .ds-choice-item__icon {\n color: var(--color-foreground-primary);\n}\n\n.select-option-row {\n position: relative;\n isolation: isolate;\n}\n\n.select-option-row__action {\n position: absolute;\n top: 50%;\n right: var(--dimension-space-050);\n z-index: var(--dimension-z-index-floating);\n display: flex;\n align-items: center;\n justify-content: center;\n border-radius: var(--dimension-radius-half);\n background: var(--color-background-transparent);\n -webkit-backdrop-filter: blur(var(--effect-blur-sm));\n backdrop-filter: blur(var(--effect-blur-sm));\n opacity: 0;\n pointer-events: none;\n transform: translateY(-50%);\n}\n\n.select-option-row:has(:focus-visible) .select-option-row__action,\n.select-option-row--action-open .select-option-row__action,\n.select-option-row--keyboard-active .select-option-row__action {\n opacity: 1;\n pointer-events: auto;\n}\n\n.select-option-row--action-open .select-option::after {\n background: var(--ds-interaction-hover);\n}\n\n.select-option__subtext-actions {\n display: flex;\n align-items: center;\n gap: var(--dimension-space-050);\n min-inline-size: 0;\n color: var(--color-foreground-secondary);\n}\n\n.select-option__subtext-action {\n appearance: none;\n display: inline-flex;\n align-items: center;\n padding: 0;\n border: 0;\n border-radius: var(--dimension-radius-025);\n background: transparent;\n cursor: pointer;\n}\n\n.select-option__subtext-action--negative {\n --ds-text-action-foreground: var(--color-foreground-bold-negative);\n}\n\n.select-option__subtext-action-separator {\n color: var(--color-foreground-tertiary);\n pointer-events: none;\n}\n\n@media (hover: hover) and (pointer: fine) {\n .select-option-row:hover .select-option-row__action {\n opacity: 1;\n pointer-events: auto;\n }\n\n .select-option-row:hover .select-option::after {\n background: var(--ds-interaction-hover);\n }\n}\n\n@media (hover: none) {\n .select-option-row__action {\n opacity: 1;\n pointer-events: auto;\n }\n\n .select-option-row--has-action .ds-choice-item__content {\n padding-inline-end: var(--dimension-size-400);\n }\n}\n\n.error-text {\n width: 100%;\n}\n",
955
+ "content": "@import '../../utils/choice-list.css';\n@import '../../utils/control-parts.css';\n@import '../../utils/control-width.css';\n@import '../../utils/field-stack.css';\n@import '../../utils/search-control.css';\n@import '../../utils/select-trigger.css';\n@import '../../utils/table-caption-control.css';\n@import '../../utils/typography.css';\n@import '../../styles/control-elevation.css';\n\n.wrapper--error {\n --ds-interaction-border-width: var(--dimension-stroke-width-018);\n --ds-interaction-border-color: var(--color-border-bold-negative);\n}\n\n.select-popup {\n color: var(--color-foreground-secondary);\n}\n\n.select-option[aria-selected='true'] .ds-choice-item__icon {\n color: var(--color-foreground-primary);\n}\n\n.select-option-row {\n position: relative;\n isolation: isolate;\n}\n\n.select-option-row__action {\n position: absolute;\n top: 50%;\n right: var(--dimension-space-050);\n z-index: var(--dimension-z-index-floating);\n display: flex;\n align-items: center;\n justify-content: center;\n border-radius: var(--dimension-radius-half);\n background: var(--color-background-transparent);\n -webkit-backdrop-filter: blur(var(--effect-blur-sm));\n backdrop-filter: blur(var(--effect-blur-sm));\n opacity: 0;\n pointer-events: none;\n transform: translateY(-50%);\n}\n\n.select-option-row:has(:focus-visible) .select-option-row__action,\n.select-option-row--action-open .select-option-row__action,\n.select-option-row--keyboard-active .select-option-row__action {\n opacity: 1;\n pointer-events: auto;\n}\n\n.select-option-row--action-open .select-option::after {\n background: var(--ds-interaction-hover);\n}\n\n.select-option__subtext-actions {\n display: flex;\n align-items: center;\n gap: var(--dimension-space-050);\n min-inline-size: 0;\n color: var(--color-foreground-secondary);\n}\n\n.select-option__subtext-action {\n appearance: none;\n display: inline-flex;\n align-items: center;\n padding: 0;\n border: 0;\n border-radius: var(--dimension-radius-025);\n background: transparent;\n cursor: pointer;\n}\n\n.select-option__subtext-action--negative {\n --ds-text-action-foreground: var(--color-foreground-bold-negative);\n}\n\n.select-option__subtext-action-separator {\n color: var(--color-foreground-tertiary);\n pointer-events: none;\n}\n\n@media (hover: hover) and (pointer: fine) {\n .select-option-row:hover .select-option-row__action {\n opacity: 1;\n pointer-events: auto;\n }\n\n .select-option-row:hover .select-option::after {\n background: var(--ds-interaction-hover);\n }\n}\n\n@media (hover: none) {\n .select-option-row__action {\n opacity: 1;\n pointer-events: auto;\n }\n\n .select-option-row--has-action .ds-choice-item__content {\n padding-inline-end: var(--dimension-size-400);\n }\n}\n\n.error-text {\n width: 100%;\n}\n",
937
956
  "type": "registry:ui"
938
957
  },
939
958
  {
940
959
  "path": "src/wc/components/Select/Select.tsx",
941
- "content": "import {\n AttachInternals,\n Component,\n Element,\n Event,\n EventEmitter,\n h,\n Host,\n Method,\n Prop,\n State,\n Watch,\n} from '@stencil/core';\nimport {\n controlWidthClass,\n CONTROL_SUPPORTING_TEXT_VARIANT,\n CONTROL_TEXT_VARIANT,\n DEFAULT_REQUIRED_MESSAGE,\n restoreStringArrayFormState,\n restoreStringFormState,\n setFormControlValue,\n setRepeatedFormControlValue,\n setRequiredValidity,\n type ControlWidth,\n} from '../../utils';\nimport {\n ChoiceActionFooter,\n ChoiceFooter,\n ChoiceOptionRow,\n ChoiceSearch,\n} from '../../utils/choice-list-parts';\nimport {\n choiceBackgroundClassMap,\n choiceListUsesIcons,\n choiceListUsesSubtext,\n enabledChoiceIndexes,\n filterChoiceSections,\n flattenChoiceSections,\n resolveChoiceSections,\n type ChoiceBackground,\n type ChoiceOption,\n type ChoiceSection,\n} from '../../utils/choice-list';\nimport { SelectController } from '../../utils/select-controller';\n\nexport type SelectOption = ChoiceOption;\nexport type SelectSection = ChoiceSection;\nexport type SelectBackground = ChoiceBackground;\nexport type SelectSize = 'lg' | 'md' | 'sm' | 'xs';\nexport type SelectWidth = ControlWidth;\nexport type SelectPopupAlign = 'start' | 'end';\nexport type SelectValue = string | string[];\nexport type SelectIndicator = 'down' | 'up-down';\n\nexport interface SelectOptionActionDetail {\n value: string;\n anchorId: string;\n originalEvent: MouseEvent;\n}\n\nexport interface SelectOptionSubtextActionDetail {\n value: string;\n actionValue: string;\n originalEvent: MouseEvent;\n}\n\nconst ICON_SIZE: Record<SelectSize, 'lg' | 'md' | 'sm' | 'xs'> = {\n lg: 'lg',\n md: 'md',\n sm: 'sm',\n xs: 'xs',\n};\n\nconst OPTION_ACTION_SIZE: Record<SelectSize, 'md' | 'sm' | 'xs'> = {\n lg: 'md',\n md: 'sm',\n sm: 'xs',\n xs: 'xs',\n};\n\nlet selectId = 0;\n\n@Component({\n tag: 'ds-select',\n styleUrl: 'Select.css',\n scoped: true,\n formAssociated: true,\n})\nexport class Select {\n @Element() el!: HTMLElement;\n @AttachInternals() internals!: ElementInternals;\n\n /** Flat choices. Assign arrays through the JavaScript property. */\n @Prop() options: SelectOption[] = [];\n /** Grouped choices; takes precedence over options. Assign through JavaScript. */\n @Prop() sections: SelectSection[] = [];\n /** Enable independent multi-value selection while keeping the popup open. */\n @Prop({ reflect: true }) multiple: boolean = false;\n /** Selected scalar or array value according to `multiple`. */\n @Prop({ mutable: true }) value: SelectValue = '';\n /** Controlled popup visibility. */\n @Prop({ mutable: true, reflect: true }) open: boolean = false;\n /** Native form field name. */\n @Prop({ reflect: true }) name: string | undefined;\n /** Native disabled state. */\n @Prop({ reflect: true }) disabled: boolean = false;\n /** Require one valid selected value. */\n @Prop({ reflect: true }) required: boolean = false;\n /** Validation message used when required is missing. */\n @Prop() requiredMessage: string = DEFAULT_REQUIRED_MESSAGE;\n /** Trigger text shown when no valid value is selected. */\n @Prop() placeholder: string = 'Select';\n /** Optional scalar-mode trigger text that does not change the selected option label. */\n @Prop() triggerLabel: string | undefined;\n /** Present triggerLabel with placeholder emphasis while preserving the selected value. */\n @Prop() triggerLabelPlaceholder: boolean = false;\n /** Show a supplemental notification dot beside the trigger label. */\n @Prop() dot: boolean = false;\n /** Control density. */\n @Prop() size: SelectSize = 'md';\n /** Width fit — hug content (default) or fill the parent. */\n @Prop() width: SelectWidth = 'hug';\n /** Align the popup's choice edge to the trigger start or end edge. */\n @Prop() popupAlign: SelectPopupAlign = 'start';\n /** Shared inactive treatment; removes interaction and form submission. */\n @Prop() isInactive: boolean = false;\n /** Replace the prefix with a loader and disable option interaction. */\n @Prop() isLoading: boolean = false;\n /** Show the selected interaction fill when a valid value exists. */\n @Prop() activeFill: boolean = true;\n /** Show the surface-aware inset border, including focused and invalid strokes. */\n @Prop() hasBorder: boolean = true;\n /** Optional trigger prefix icon name. */\n @Prop() icon: string | undefined;\n /** Trailing choice indicator. Use up-down for compact value steppers such as page size. */\n @Prop() indicator: SelectIndicator = 'down';\n /** Show the clear footer action when a value exists. */\n @Prop() allowClear: boolean = true;\n /** Localized clear action label. */\n @Prop() clearLabel: string = 'Clear';\n /** Localized noun displayed after the selected count in multiple mode. */\n @Prop() selectedLabel: string = 'selected';\n /** Optional text action shown in the popup footer instead of the clear action. */\n @Prop() footerActionLabel: string | undefined;\n /** Show immediate local filtering over option labels, subtext, and section headings. */\n @Prop() searchable: boolean = false;\n /** Localized search-field placeholder and accessible name. */\n @Prop() searchPlaceholder: string = 'Search';\n /** Localized empty-filter result text. */\n @Prop() noResultsText: string = 'No results found';\n /** Accessible loading status label. */\n @Prop() loadingLabel: string = 'Loading';\n /** Actual parent surface context; omit on primary and secondary surfaces. */\n @Prop() background: SelectBackground | undefined;\n /** Show invalid visual state. */\n @Prop() error: boolean = false;\n /** Error text rendered below the trigger when error is true. */\n @Prop() errorMessage: string | undefined;\n /** ID applied to the internal combobox trigger for external labels. */\n @Prop() inputId: string | undefined;\n /** Direct accessible name when no external label is available. */\n @Prop({ attribute: 'aria-label' }) ariaLabel: string | null = null;\n /** IDs of elements that label the combobox. */\n @Prop({ attribute: 'aria-labelledby' }) ariaLabelledby: string | undefined;\n /** Additional IDs that describe the combobox. */\n @Prop({ attribute: 'aria-describedby' }) ariaDescribedby: string | undefined;\n\n /** Emitted after selection or clearing with the next scalar or array value. */\n @Event() dsChange!: EventEmitter<SelectValue>;\n /** Emitted after the footer clear action. */\n @Event() dsClear!: EventEmitter<void>;\n /** Emitted whenever popup visibility changes. */\n @Event() dsOpenChange!: EventEmitter<boolean>;\n /** Emitted when the optional popup footer action is activated. */\n @Event() dsFooterAction!: EventEmitter<void>;\n /** Emitted when an option's contextual ellipsis action is activated. */\n @Event() dsOptionAction!: EventEmitter<SelectOptionActionDetail>;\n /** Emitted when an option's supporting text action is activated. */\n @Event() dsOptionSubtextAction!: EventEmitter<SelectOptionSubtextActionDetail>;\n\n @State() private activeIndex = -1;\n @State() private searchTerm = '';\n @State() private formDisabled = false;\n @State() private position = { x: 0, y: 0 };\n @State() private positionReady = false;\n @State() private focusRingVisible = false;\n @State() private compactFooterSummary = false;\n\n private readonly generatedId = `ds-select-${++selectId}`;\n private readonly listboxId = `${this.generatedId}-listbox`;\n private readonly errorId = `${this.generatedId}-error`;\n private initialValue: SelectValue = '';\n private readonly controller = this.createController();\n private footerContentElement?: HTMLDivElement;\n private footerSummaryMeasureElement?: HTMLElement;\n private footerClearElement?: HTMLButtonElement;\n private footerResizeObserver?: ResizeObserver;\n private observedFooterContentElement?: HTMLDivElement;\n private footerMeasurementFrame?: number;\n\n private createController() {\n // eslint-disable-next-line @typescript-eslint/no-this-alias -- adapter getters preserve reactive component ownership without inheritance.\n const owner = this;\n return new SelectController<SelectOption>({\n get host() {\n return owner.el;\n },\n generatedId: owner.generatedId,\n get options() {\n return owner.visibleOptions;\n },\n get searchable() {\n return owner.searchable;\n },\n get isLoading() {\n return owner.isLoading;\n },\n get isDisabled() {\n return owner.isDisabled;\n },\n get preferredIndex() {\n if (owner.multiple) return -1;\n return owner.visibleOptions.findIndex(\n option => option.value === owner.scalarValue && !option.isInactive\n );\n },\n get popupAlign() {\n return owner.popupAlign;\n },\n get open() {\n return owner.open;\n },\n set open(value) {\n owner.open = value;\n },\n get activeIndex() {\n return owner.activeIndex;\n },\n set activeIndex(value) {\n owner.activeIndex = value;\n },\n get searchTerm() {\n return owner.searchTerm;\n },\n set searchTerm(value) {\n owner.searchTerm = value;\n },\n get focusRingVisible() {\n return owner.focusRingVisible;\n },\n set focusRingVisible(value) {\n owner.focusRingVisible = value;\n },\n get position() {\n return owner.position;\n },\n set position(value) {\n owner.position = value;\n },\n get positionReady() {\n return owner.positionReady;\n },\n set positionReady(value) {\n owner.positionReady = value;\n },\n selectOption: option => owner.selectOption(option),\n });\n }\n\n componentWillLoad() {\n this.value = this.normalizeValueForMode(this.value);\n this.initialValue = Array.isArray(this.value) ? [...this.value] : this.value;\n this.syncFormValue();\n }\n\n componentDidLoad() {\n this.controller.connect();\n this.observeFooterContent();\n }\n\n componentDidRender() {\n this.observeFooterContent();\n this.scheduleFooterSummaryMeasurement();\n }\n\n disconnectedCallback() {\n this.controller.disconnect();\n this.footerResizeObserver?.disconnect();\n this.observedFooterContentElement = undefined;\n if (this.footerMeasurementFrame !== undefined) {\n cancelAnimationFrame(this.footerMeasurementFrame);\n }\n }\n\n @Watch('value')\n @Watch('multiple')\n @Watch('disabled')\n @Watch('isInactive')\n @Watch('required')\n @Watch('name')\n @Watch('options')\n @Watch('sections')\n syncFormValue() {\n if (\n (this.multiple && !Array.isArray(this.value)) ||\n (!this.multiple && Array.isArray(this.value))\n ) {\n this.value = this.normalizeValueForMode(this.value);\n }\n const inactive = this.isDisabled;\n if (this.multiple) {\n const values = this.resolvedValues;\n setRepeatedFormControlValue(this.internals, this.name, values, { inactive });\n } else {\n const value = this.hasSelection ? this.scalarValue : '';\n setFormControlValue(this.internals, value, { inactive });\n }\n const missing = this.required && !inactive && !this.hasSelection;\n setRequiredValidity(this.internals, missing, this.requiredMessage);\n this.controller.optionsChanged();\n }\n\n @Watch('isLoading')\n onLoadingChange() {\n this.controller.loadingChanged();\n }\n\n @Watch('popupAlign')\n onPopupAlignChange() {\n this.controller.positionChanged();\n }\n\n @Watch('open')\n onOpenChange(open: boolean) {\n this.dsOpenChange.emit(open);\n this.controller.openChanged(open);\n }\n\n @Watch('searchTerm')\n onSearchTermChange() {\n this.controller.searchChanged();\n }\n\n @Watch('activeIndex')\n onActiveIndexChange() {\n this.controller.activeIndexChanged();\n }\n\n formDisabledCallback(disabled: boolean) {\n this.formDisabled = disabled;\n this.syncFormValue();\n }\n\n formResetCallback() {\n this.value = this.normalizeValueForMode(this.initialValue);\n this.closePopup();\n }\n\n formStateRestoreCallback(state: string | File | FormData | null) {\n if (!this.multiple) {\n this.value = restoreStringFormState(state);\n return;\n }\n this.value = restoreStringArrayFormState(state);\n }\n\n @Method()\n async setFocus() {\n this.controller.setFocus();\n }\n\n private get isDisabled(): boolean {\n return this.disabled || this.isInactive || this.formDisabled;\n }\n\n private normalizeValueForMode(value: SelectValue): SelectValue {\n if (this.multiple) {\n return Array.isArray(value) ? [...value] : value ? [value] : [];\n }\n return Array.isArray(value) ? (value[0] ?? '') : value;\n }\n\n private get allSections(): SelectSection[] {\n return resolveChoiceSections(this.options, this.sections);\n }\n\n private get allOptions(): SelectOption[] {\n return flattenChoiceSections(this.allSections);\n }\n\n private get visibleSections(): SelectSection[] {\n return this.searchable\n ? filterChoiceSections(this.allSections, this.searchTerm)\n : this.allSections;\n }\n\n private get visibleOptions(): SelectOption[] {\n return flattenChoiceSections(this.visibleSections);\n }\n\n private get scalarValue(): string {\n return typeof this.value === 'string' ? this.value : '';\n }\n\n private get resolvedValues(): string[] {\n if (!this.multiple || !Array.isArray(this.value)) return [];\n const valid = new Set(this.allOptions.map(option => option.value));\n return [...new Set(this.value.filter(value => valid.has(value)))];\n }\n\n private get selectedOption(): SelectOption | undefined {\n if (this.multiple) return undefined;\n return this.allOptions.find(option => option.value === this.scalarValue);\n }\n\n private get hasSelection(): boolean {\n return this.multiple ? this.resolvedValues.length > 0 : Boolean(this.selectedOption);\n }\n\n private get activeOptionId(): string | undefined {\n return this.controller.activeOptionId;\n }\n\n private get popupRole(): 'listbox' | 'grid' {\n return this.allOptions.some(option => Boolean(option.action)) ? 'grid' : 'listbox';\n }\n\n private observeFooterContent() {\n const content =\n this.open && this.multiple && this.hasSelection ? this.footerContentElement : undefined;\n if (!content?.isConnected) {\n this.footerResizeObserver?.disconnect();\n this.observedFooterContentElement = undefined;\n return;\n }\n if (this.observedFooterContentElement === content) return;\n\n this.footerResizeObserver?.disconnect();\n if (typeof ResizeObserver !== 'undefined') {\n this.footerResizeObserver = new ResizeObserver(() => this.scheduleFooterSummaryMeasurement());\n this.footerResizeObserver.observe(content);\n }\n this.observedFooterContentElement = content;\n }\n\n private scheduleFooterSummaryMeasurement() {\n if (\n !this.open ||\n !this.multiple ||\n !this.hasSelection ||\n this.footerMeasurementFrame !== undefined\n ) {\n return;\n }\n this.footerMeasurementFrame = requestAnimationFrame(() => {\n this.footerMeasurementFrame = undefined;\n this.measureFooterSummary();\n });\n }\n\n private measureFooterSummary() {\n const content = this.footerContentElement;\n const summary = this.footerSummaryMeasureElement;\n const clear = this.footerClearElement;\n if (!content?.isConnected || !summary?.isConnected || !clear?.isConnected) return;\n\n const styles = getComputedStyle(content);\n const innerWidth =\n content.clientWidth -\n Number.parseFloat(styles.paddingInlineStart || '0') -\n Number.parseFloat(styles.paddingInlineEnd || '0');\n const requiredWidth =\n summary.getBoundingClientRect().width +\n clear.getBoundingClientRect().width +\n Number.parseFloat(styles.columnGap || '0');\n const nextCompact = requiredWidth > innerWidth + 0.5;\n if (nextCompact !== this.compactFooterSummary) {\n this.compactFooterSummary = nextCompact;\n }\n }\n\n private closePopup(restoreFocus = false) {\n this.controller.closePopup(restoreFocus);\n }\n\n private openPopup(focusVisible: boolean, edge?: 'first' | 'last') {\n this.controller.openPopup(focusVisible, edge);\n }\n\n private selectOption(option: SelectOption) {\n if (option.isInactive || this.isLoading) return;\n if (this.multiple) {\n const current = this.resolvedValues;\n const next = current.includes(option.value)\n ? current.filter(value => value !== option.value)\n : [...current, option.value];\n this.value = next;\n this.dsChange.emit([...next]);\n return;\n }\n this.value = option.value;\n this.dsChange.emit(option.value);\n this.controller.closePopup(true);\n }\n\n private clearSelection = (event: Event) => {\n event.preventDefault();\n event.stopPropagation();\n this.value = this.multiple ? [] : '';\n this.dsChange.emit(this.multiple ? [] : '');\n this.dsClear.emit();\n if (!this.multiple) {\n const enabled = enabledChoiceIndexes(this.visibleOptions);\n this.activeIndex = enabled[0] ?? -1;\n }\n this.controller.focusSearchOrTrigger();\n };\n\n private activateFooterAction = (event: MouseEvent) => {\n event.preventDefault();\n event.stopPropagation();\n this.dsFooterAction.emit();\n this.closePopup();\n };\n\n private readonly handleListKeyDown = this.controller.handleListKeyDown;\n private readonly handleTriggerKeyDown = this.controller.handleTriggerKeyDown;\n\n private renderOption(\n option: SelectOption,\n index: number,\n usesIcons: boolean,\n usesSubtext: boolean\n ) {\n const selected = this.multiple\n ? this.resolvedValues.includes(option.value)\n : option.value === this.scalarValue;\n const active = index === this.activeIndex;\n const actionId = `${this.generatedId}-option-action-${index}`;\n return (\n <ChoiceOptionRow\n size={this.size}\n id={`${this.generatedId}-option-${index}`}\n option={option}\n selected={selected}\n active={active}\n focusRingVisible={this.focusRingVisible}\n usesSubtext={usesSubtext}\n actionOpen={option.action?.expanded}\n popupRole={this.popupRole}\n leading={\n this.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\n class=\"select-option__checkbox\"\n label=\"\"\n size={this.size}\n checked={selected}\n presentation\n />\n </span>\n ) : usesIcons && option.icon ? (\n <span\n class=\"ds-choice-item__icon ds-control-icon-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n <ds-icon name={option.icon} size={this.size} color=\"inherit\" />\n </span>\n ) : undefined\n }\n supporting={\n option.subtextActions?.length ? (\n <span class=\"ds-choice-item__subtext ds-control-label-box select-option__subtext-actions\">\n {option.subtextActions.map((action, actionIndex) => [\n actionIndex > 0 ? (\n <ds-text\n class=\"select-option__subtext-action-separator\"\n as=\"span\"\n variant={CONTROL_SUPPORTING_TEXT_VARIANT[this.size]}\n color=\"inherit\"\n aria-hidden=\"true\"\n >\n ·\n </ds-text>\n ) : null,\n <button\n type=\"button\"\n class={{\n 'select-option__subtext-action': true,\n 'select-option__subtext-action--negative': action.tone === 'negative',\n 'ds-text-action': true,\n 'ds-focus-ring': true,\n }}\n onKeyDown={(event: KeyboardEvent) => event.stopPropagation()}\n onClick={(event: MouseEvent) => {\n event.preventDefault();\n event.stopPropagation();\n this.dsOptionSubtextAction.emit({\n value: option.value,\n actionValue: action.value,\n originalEvent: event,\n });\n }}\n >\n <ds-text\n as=\"span\"\n variant={CONTROL_SUPPORTING_TEXT_VARIANT[this.size]}\n color=\"inherit\"\n >\n {action.label}\n </ds-text>\n </button>,\n ])}\n </span>\n ) : undefined\n }\n action={\n option.action ? (\n <ds-tooltip label={option.action.label} side=\"left\" size=\"sm\">\n <ds-button-unfilled\n id={actionId}\n variant=\"icon\"\n size={OPTION_ACTION_SIZE[this.size]}\n icon=\"Ellipses\"\n rounded\n hasBorder={false}\n activeFill={false}\n ariaLabel={option.action.label}\n haspopup=\"menu\"\n controls={option.action.controls}\n expanded={option.action.expanded}\n onKeyDown={event => {\n event.stopPropagation();\n if (event.key === 'Escape' || event.key === 'ArrowLeft') {\n event.preventDefault();\n this.controller.focusSearchOrTrigger();\n }\n }}\n onDsClick={(event: CustomEvent<MouseEvent>) => {\n event.stopPropagation();\n this.dsOptionAction.emit({\n value: option.value,\n anchorId: actionId,\n originalEvent: event.detail,\n });\n }}\n />\n </ds-tooltip>\n ) : undefined\n }\n onHover={() => {\n this.focusRingVisible = false;\n this.activeIndex = index;\n }}\n onSelect={() => this.selectOption(option)}\n />\n );\n }\n\n render() {\n const inactive = this.isDisabled;\n const showPlaceholder = !this.hasSelection;\n const count = this.resolvedValues.length;\n const label = this.multiple\n ? `${this.placeholder}${count > 0 ? ` · ${count}` : ''}`\n : showPlaceholder\n ? this.placeholder\n : (this.triggerLabel ?? this.selectedOption?.label);\n const textVariant = CONTROL_TEXT_VARIANT[this.size];\n const iconSize = ICON_SIZE[this.size];\n const usesOptionIcons = !this.multiple && choiceListUsesIcons(this.allOptions);\n const usesOptionSubtext = choiceListUsesSubtext(this.allOptions);\n const showError = this.error && Boolean(this.errorMessage);\n const describedBy =\n [this.ariaDescribedby, showError ? this.errorId : undefined].filter(Boolean).join(' ') ||\n undefined;\n const popupStyle = {\n position: 'fixed',\n left: '0',\n top: '0',\n transform: `translate(${Math.round(this.position.x)}px, ${Math.round(this.position.y)}px)`,\n zIndex: 'var(--dimension-z-index-floating)',\n visibility: this.positionReady ? 'visible' : 'hidden',\n };\n let flatIndex = 0;\n\n return (\n <Host\n class={{\n 'select-host': true,\n 'ds-select-trigger-host': true,\n 'select-host--multiple': this.multiple,\n 'ds-field-stack': true,\n 'ds-control-inactive': inactive,\n [`ds-control--${this.size}`]: true,\n ...controlWidthClass(this.width),\n [`select-host--background-${this.background}`]: !!this.background,\n [`ds-select-trigger-host--background-${this.background}`]: !!this.background,\n }}\n >\n <button\n ref={element => {\n this.controller.setTriggerElement((element as HTMLButtonElement) ?? null);\n }}\n id={this.inputId ?? this.generatedId}\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': !inactive && this.activeFill && this.hasSelection,\n 'trigger--expanded': !inactive && this.open,\n 'trigger--bordered': this.hasBorder,\n 'trigger--placeholder': showPlaceholder && !this.multiple,\n 'trigger--has-value': this.hasSelection,\n 'trigger--label-placeholder':\n !this.multiple && Boolean(this.triggerLabel) && this.triggerLabelPlaceholder,\n 'wrapper--error': this.hasBorder && this.error,\n [`ds-control--${this.size}`]: true,\n ...choiceBackgroundClassMap(this.background),\n }}\n disabled={inactive}\n role=\"combobox\"\n aria-haspopup={this.popupRole}\n aria-expanded={String(this.open)}\n aria-controls={this.open ? this.listboxId : undefined}\n aria-activedescendant={this.open && !this.searchable ? this.activeOptionId : undefined}\n aria-label={this.ariaLabel}\n aria-labelledby={this.ariaLabelledby}\n aria-describedby={describedBy}\n aria-invalid={this.error ? 'true' : undefined}\n aria-required={this.required || undefined}\n aria-busy={this.isLoading ? 'true' : undefined}\n onClick={() => (this.open ? this.closePopup() : this.openPopup(false))}\n onKeyDown={event => {\n const action = this.visibleOptions[this.activeIndex]?.action;\n if (\n this.open &&\n action &&\n (event.key === 'ArrowRight' || (event.key === 'F10' && event.shiftKey))\n ) {\n event.preventDefault();\n document\n .getElementById(`${this.generatedId}-option-action-${this.activeIndex}`)\n ?.querySelector<HTMLElement>('button')\n ?.focus();\n return;\n }\n this.handleTriggerKeyDown(event);\n }}\n >\n {(this.icon || this.isLoading) && (\n <span\n class=\"trigger__prefix ds-control-icon-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n {this.isLoading ? (\n <ds-loader size={iconSize} color=\"inherit\" />\n ) : (\n <ds-icon name={this.icon} size={iconSize} color=\"inherit\" />\n )}\n </span>\n )}\n <span\n class={{\n 'trigger__label-box': true,\n 'ds-interaction-fill__content': true,\n }}\n >\n <span\n class={{\n 'trigger__label-content': true,\n 'ds-control-label-box': true,\n 'ds-control-label-dot': this.dot,\n 'trigger__label-content--dot': this.dot,\n }}\n >\n <ds-text\n class=\"trigger__label\"\n as=\"span\"\n variant={textVariant}\n color=\"inherit\"\n lineTruncation={1}\n >\n {label}\n </ds-text>\n {this.dot && (\n <ds-badge\n class=\"trigger__dot ds-control-label-dot__badge\"\n variant=\"dot\"\n hasRing={false}\n label=\"\"\n aria-hidden=\"true\"\n />\n )}\n </span>\n </span>\n <span\n class=\"trigger__chevron ds-control-icon-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n <ds-icon\n name={this.indicator === 'up-down' ? 'ChevronUpDown' : 'ChevronDown'}\n size={iconSize}\n color=\"inherit\"\n />\n </span>\n </button>\n\n {this.open && (\n <div\n popover=\"manual\"\n ref={element => {\n this.controller.setPopupElement((element as HTMLDivElement) ?? null);\n }}\n class=\"select-popup ds-choice-popup\"\n style={popupStyle}\n >\n {this.searchable && (\n <ChoiceSearch\n size={this.size}\n value={this.searchTerm}\n placeholder={this.searchPlaceholder}\n controls={this.listboxId}\n activeDescendant={this.activeOptionId}\n inputRef={this.controller.setSearchElement}\n clearLabel={this.clearLabel}\n onValueChange={value => {\n this.searchTerm = value;\n }}\n onKeyDown={event => this.handleListKeyDown(event)}\n />\n )}\n <div\n id={this.listboxId}\n class=\"ds-choice-list\"\n role={this.popupRole}\n aria-multiselectable={this.multiple ? 'true' : undefined}\n aria-label={this.ariaLabel ?? this.placeholder}\n aria-busy={this.isLoading ? 'true' : undefined}\n >\n {this.isLoading ? (\n <div\n class=\"ds-choice-loading ds-empty-region\"\n role={this.popupRole === 'grid' ? 'row' : 'option'}\n aria-selected=\"false\"\n aria-disabled=\"true\"\n aria-label={this.loadingLabel}\n aria-live=\"polite\"\n >\n {this.popupRole === 'grid' ? (\n <span role=\"gridcell\">\n <ds-loader size={this.size} color=\"inherit\" />\n </span>\n ) : (\n <ds-loader size={this.size} color=\"inherit\" />\n )}\n </div>\n ) : this.visibleOptions.length === 0 ? (\n <div\n class=\"ds-choice-empty ds-empty-region\"\n role={this.popupRole === 'grid' ? 'row' : 'option'}\n aria-selected=\"false\"\n aria-disabled=\"true\"\n aria-label={this.noResultsText}\n aria-live=\"polite\"\n >\n {this.popupRole === 'grid' ? (\n <span role=\"gridcell\">\n <ds-empty-state body={this.noResultsText} />\n </span>\n ) : (\n <ds-empty-state body={this.noResultsText} />\n )}\n </div>\n ) : (\n this.visibleSections.map((section, sectionIndex) => (\n <div\n class={{\n 'ds-choice-section': true,\n 'ds-chrome-column': true,\n 'ds-chrome-space--sm': true,\n 'ds-choice-section--divided': !!section.divider,\n 'ds-choice-section--headed-after-first':\n sectionIndex > 0 && Boolean(section.header),\n }}\n role={\n this.popupRole === 'grid' ? 'rowgroup' : section.header ? 'group' : undefined\n }\n aria-label={section.header}\n >\n {section.header && this.popupRole === 'grid' ? (\n <ds-text\n class={`ds-choice-section__header ds-control-section-heading ds-control--${this.size}`}\n as=\"span\"\n variant={CONTROL_SUPPORTING_TEXT_VARIANT[this.size]}\n emphasis\n color=\"primary\"\n role=\"row\"\n >\n <span class=\"ds-choice-section__header-label\" role=\"gridcell\">\n {section.header}\n </span>\n </ds-text>\n ) : section.header ? (\n <ds-text\n class={`ds-choice-section__header ds-control-section-heading ds-control--${this.size}`}\n as=\"span\"\n variant={CONTROL_SUPPORTING_TEXT_VARIANT[this.size]}\n emphasis\n color=\"primary\"\n aria-hidden=\"true\"\n >\n <span class=\"ds-choice-section__header-label\">{section.header}</span>\n </ds-text>\n ) : null}\n {section.options.map(option =>\n this.renderOption(option, flatIndex++, usesOptionIcons, usesOptionSubtext)\n )}\n </div>\n ))\n )}\n </div>\n {this.footerActionLabel && !this.isLoading ? (\n <ChoiceActionFooter\n size={this.size}\n label={this.footerActionLabel}\n onAction={this.activateFooterAction}\n />\n ) : this.allowClear && this.hasSelection && !this.isLoading ? (\n <ChoiceFooter\n size={this.size}\n summary={this.multiple ? `${count} ${this.selectedLabel}` : undefined}\n compactSummary={this.multiple ? String(count) : undefined}\n useCompactSummary={this.compactFooterSummary}\n contentRef={element => {\n this.footerContentElement = element ?? undefined;\n }}\n summaryMeasureRef={element => {\n this.footerSummaryMeasureElement = element ?? undefined;\n }}\n clearRef={element => {\n this.footerClearElement = element ?? undefined;\n }}\n clearLabel={this.clearLabel}\n onClear={this.clearSelection}\n />\n ) : null}\n </div>\n )}\n\n {showError && (\n <ds-text\n class=\"error-text\"\n as=\"div\"\n variant=\"text-body-small\"\n color=\"negative\"\n textId={this.errorId}\n role=\"alert\"\n >\n {this.errorMessage}\n </ds-text>\n )}\n </Host>\n );\n }\n}\n",
960
+ "content": "import {\n AttachInternals,\n Component,\n Element,\n Event,\n EventEmitter,\n h,\n Host,\n Method,\n Prop,\n State,\n Watch,\n} from '@stencil/core';\nimport {\n controlWidthClass,\n CONTROL_SUPPORTING_TEXT_VARIANT,\n CONTROL_TEXT_VARIANT,\n DEFAULT_REQUIRED_MESSAGE,\n restoreStringArrayFormState,\n restoreStringFormState,\n setFormControlValue,\n setRepeatedFormControlValue,\n setRequiredValidity,\n type ControlWidth,\n} from '../../utils';\nimport {\n ChoiceActionFooter,\n ChoiceFooter,\n ChoiceOptionRow,\n ChoiceSearch,\n} from '../../utils/choice-list-parts';\nimport {\n choiceBackgroundClassMap,\n choiceListUsesIcons,\n choiceListUsesSubtext,\n enabledChoiceIndexes,\n filterChoiceSections,\n flattenChoiceSections,\n resolveChoiceSections,\n type ChoiceBackground,\n type ChoiceOption,\n type ChoiceSection,\n} from '../../utils/choice-list';\nimport { SelectController } from '../../utils/select-controller';\nimport { observeTableCaptionCompact } from '../../utils/table-caption-compact';\n\nexport type SelectOption = ChoiceOption;\nexport type SelectSection = ChoiceSection;\nexport type SelectBackground = ChoiceBackground;\nexport type SelectSize = 'lg' | 'md' | 'sm' | 'xs';\nexport type SelectWidth = ControlWidth;\nexport type SelectPopupAlign = 'start' | 'end';\nexport type SelectValue = string | string[];\nexport type SelectIndicator = 'down' | 'up-down';\n\nexport interface SelectOptionActionDetail {\n value: string;\n anchorId: string;\n originalEvent: MouseEvent;\n}\n\nexport interface SelectOptionSubtextActionDetail {\n value: string;\n actionValue: string;\n originalEvent: MouseEvent;\n}\n\nconst ICON_SIZE: Record<SelectSize, 'lg' | 'md' | 'sm' | 'xs'> = {\n lg: 'lg',\n md: 'md',\n sm: 'sm',\n xs: 'xs',\n};\n\nconst OPTION_ACTION_SIZE: Record<SelectSize, 'md' | 'sm' | 'xs'> = {\n lg: 'md',\n md: 'sm',\n sm: 'xs',\n xs: 'xs',\n};\n\nlet selectId = 0;\n\n@Component({\n tag: 'ds-select',\n styleUrl: 'Select.css',\n scoped: true,\n formAssociated: true,\n})\nexport class Select {\n @Element() el!: HTMLElement;\n @AttachInternals() internals!: ElementInternals;\n\n /** Flat choices. Assign arrays through the JavaScript property. */\n @Prop() options: SelectOption[] = [];\n /** Grouped choices; takes precedence over options. Assign through JavaScript. */\n @Prop() sections: SelectSection[] = [];\n /** Enable independent multi-value selection while keeping the popup open. */\n @Prop({ reflect: true }) multiple: boolean = false;\n /** Selected scalar or array value according to `multiple`. */\n @Prop({ mutable: true }) value: SelectValue = '';\n /** Controlled popup visibility. */\n @Prop({ mutable: true, reflect: true }) open: boolean = false;\n /** Native form field name. */\n @Prop({ reflect: true }) name: string | undefined;\n /** Native disabled state. */\n @Prop({ reflect: true }) disabled: boolean = false;\n /** Require one valid selected value. */\n @Prop({ reflect: true }) required: boolean = false;\n /** Validation message used when required is missing. */\n @Prop() requiredMessage: string = DEFAULT_REQUIRED_MESSAGE;\n /** Trigger text shown when no valid value is selected. */\n @Prop() placeholder: string = 'Select';\n /** Optional scalar-mode trigger text that does not change the selected option label. */\n @Prop() triggerLabel: string | undefined;\n /** Present triggerLabel with placeholder emphasis while preserving the selected value. */\n @Prop() triggerLabelPlaceholder: boolean = false;\n /** Show a supplemental notification dot beside the trigger label. */\n @Prop() dot: boolean = false;\n /** Control density. */\n @Prop() size: SelectSize = 'md';\n /** Width fit — hug content (default) or fill the parent. */\n @Prop() width: SelectWidth = 'hug';\n /** Align the popup's choice edge to the trigger start or end edge. */\n @Prop() popupAlign: SelectPopupAlign = 'start';\n /** Shared inactive treatment; removes interaction and form submission. */\n @Prop() isInactive: boolean = false;\n /** Replace the prefix with a loader and disable option interaction. */\n @Prop() isLoading: boolean = false;\n /** Show the selected interaction fill when a valid value exists. */\n @Prop() activeFill: boolean = true;\n /**\n * Opt into table-caption icon-only chrome below 900px. The trigger omits its\n * visible label and chevron; keep an accessible name via aria-label.\n */\n @Prop({ reflect: true }) collapseLabel: boolean = false;\n /** Show the surface-aware inset border, including focused and invalid strokes. */\n @Prop() hasBorder: boolean = true;\n /** Optional trigger prefix icon name. */\n @Prop() icon: string | undefined;\n /** Trailing choice indicator. Use up-down for compact value steppers such as page size. */\n @Prop() indicator: SelectIndicator = 'down';\n /** Show the clear footer action when a value exists. */\n @Prop() allowClear: boolean = true;\n /** Localized clear action label. */\n @Prop() clearLabel: string = 'Clear';\n /** Localized noun displayed after the selected count in multiple mode. */\n @Prop() selectedLabel: string = 'selected';\n /** Optional text action shown in the popup footer instead of the clear action. */\n @Prop() footerActionLabel: string | undefined;\n /** Show immediate local filtering over option labels, subtext, and section headings. */\n @Prop() searchable: boolean = false;\n /** Localized search-field placeholder and accessible name. */\n @Prop() searchPlaceholder: string = 'Search';\n /** Localized empty-filter result text. */\n @Prop() noResultsText: string = 'No results found';\n /** Accessible loading status label. */\n @Prop() loadingLabel: string = 'Loading';\n /** Actual parent surface context; omit on primary and secondary surfaces. */\n @Prop() background: SelectBackground | undefined;\n /** Show invalid visual state. */\n @Prop() error: boolean = false;\n /** Error text rendered below the trigger when error is true. */\n @Prop() errorMessage: string | undefined;\n /** ID applied to the internal combobox trigger for external labels. */\n @Prop() inputId: string | undefined;\n /** Direct accessible name when no external label is available. */\n @Prop({ attribute: 'aria-label' }) ariaLabel: string | null = null;\n /** IDs of elements that label the combobox. */\n @Prop({ attribute: 'aria-labelledby' }) ariaLabelledby: string | undefined;\n /** Additional IDs that describe the combobox. */\n @Prop({ attribute: 'aria-describedby' }) ariaDescribedby: string | undefined;\n\n /** Emitted after selection or clearing with the next scalar or array value. */\n @Event() dsChange!: EventEmitter<SelectValue>;\n /** Emitted after the footer clear action. */\n @Event() dsClear!: EventEmitter<void>;\n /** Emitted whenever popup visibility changes. */\n @Event() dsOpenChange!: EventEmitter<boolean>;\n /** Emitted when the optional popup footer action is activated. */\n @Event() dsFooterAction!: EventEmitter<void>;\n /** Emitted when an option's contextual ellipsis action is activated. */\n @Event() dsOptionAction!: EventEmitter<SelectOptionActionDetail>;\n /** Emitted when an option's supporting text action is activated. */\n @Event() dsOptionSubtextAction!: EventEmitter<SelectOptionSubtextActionDetail>;\n\n @State() private activeIndex = -1;\n @State() private searchTerm = '';\n @State() private formDisabled = false;\n @State() private position = { x: 0, y: 0 };\n @State() private positionReady = false;\n @State() private focusRingVisible = false;\n @State() private compactFooterSummary = false;\n @State() private captionCompact = false;\n\n private readonly generatedId = `ds-select-${++selectId}`;\n private readonly listboxId = `${this.generatedId}-listbox`;\n private readonly errorId = `${this.generatedId}-error`;\n private initialValue: SelectValue = '';\n private readonly controller = this.createController();\n private footerContentElement?: HTMLDivElement;\n private footerSummaryMeasureElement?: HTMLElement;\n private footerClearElement?: HTMLButtonElement;\n private footerResizeObserver?: ResizeObserver;\n private observedFooterContentElement?: HTMLDivElement;\n private footerMeasurementFrame?: number;\n private captionCompactDisconnect: (() => void) | undefined;\n\n private createController() {\n // eslint-disable-next-line @typescript-eslint/no-this-alias -- adapter getters preserve reactive component ownership without inheritance.\n const owner = this;\n return new SelectController<SelectOption>({\n get host() {\n return owner.el;\n },\n generatedId: owner.generatedId,\n get options() {\n return owner.visibleOptions;\n },\n get searchable() {\n return owner.searchable;\n },\n get isLoading() {\n return owner.isLoading;\n },\n get isDisabled() {\n return owner.isDisabled;\n },\n get preferredIndex() {\n if (owner.multiple) return -1;\n return owner.visibleOptions.findIndex(\n option => option.value === owner.scalarValue && !option.isInactive\n );\n },\n get popupAlign() {\n return owner.popupAlign;\n },\n get open() {\n return owner.open;\n },\n set open(value) {\n owner.open = value;\n },\n get activeIndex() {\n return owner.activeIndex;\n },\n set activeIndex(value) {\n owner.activeIndex = value;\n },\n get searchTerm() {\n return owner.searchTerm;\n },\n set searchTerm(value) {\n owner.searchTerm = value;\n },\n get focusRingVisible() {\n return owner.focusRingVisible;\n },\n set focusRingVisible(value) {\n owner.focusRingVisible = value;\n },\n get position() {\n return owner.position;\n },\n set position(value) {\n owner.position = value;\n },\n get positionReady() {\n return owner.positionReady;\n },\n set positionReady(value) {\n owner.positionReady = value;\n },\n selectOption: option => owner.selectOption(option),\n });\n }\n\n componentWillLoad() {\n this.value = this.normalizeValueForMode(this.value);\n this.initialValue = Array.isArray(this.value) ? [...this.value] : this.value;\n this.syncFormValue();\n }\n\n componentDidLoad() {\n this.controller.connect();\n this.observeFooterContent();\n this.syncCaptionCompactObserver();\n }\n\n componentDidRender() {\n this.observeFooterContent();\n this.scheduleFooterSummaryMeasurement();\n }\n\n disconnectedCallback() {\n this.controller.disconnect();\n this.footerResizeObserver?.disconnect();\n this.observedFooterContentElement = undefined;\n if (this.footerMeasurementFrame !== undefined) {\n cancelAnimationFrame(this.footerMeasurementFrame);\n }\n this.disconnectCaptionCompactObserver();\n }\n\n @Watch('collapseLabel')\n onCollapseLabelChange() {\n this.syncCaptionCompactObserver();\n }\n\n @Watch('value')\n @Watch('multiple')\n @Watch('disabled')\n @Watch('isInactive')\n @Watch('required')\n @Watch('name')\n @Watch('options')\n @Watch('sections')\n syncFormValue() {\n if (\n (this.multiple && !Array.isArray(this.value)) ||\n (!this.multiple && Array.isArray(this.value))\n ) {\n this.value = this.normalizeValueForMode(this.value);\n }\n const inactive = this.isDisabled;\n if (this.multiple) {\n const values = this.resolvedValues;\n setRepeatedFormControlValue(this.internals, this.name, values, { inactive });\n } else {\n const value = this.hasSelection ? this.scalarValue : '';\n setFormControlValue(this.internals, value, { inactive });\n }\n const missing = this.required && !inactive && !this.hasSelection;\n setRequiredValidity(this.internals, missing, this.requiredMessage);\n this.controller.optionsChanged();\n }\n\n @Watch('isLoading')\n onLoadingChange() {\n this.controller.loadingChanged();\n }\n\n @Watch('popupAlign')\n onPopupAlignChange() {\n this.controller.positionChanged();\n }\n\n @Watch('open')\n onOpenChange(open: boolean) {\n this.dsOpenChange.emit(open);\n this.controller.openChanged(open);\n }\n\n @Watch('searchTerm')\n onSearchTermChange() {\n this.controller.searchChanged();\n }\n\n @Watch('activeIndex')\n onActiveIndexChange() {\n this.controller.activeIndexChanged();\n }\n\n formDisabledCallback(disabled: boolean) {\n this.formDisabled = disabled;\n this.syncFormValue();\n }\n\n formResetCallback() {\n this.value = this.normalizeValueForMode(this.initialValue);\n this.closePopup();\n }\n\n formStateRestoreCallback(state: string | File | FormData | null) {\n if (!this.multiple) {\n this.value = restoreStringFormState(state);\n return;\n }\n this.value = restoreStringArrayFormState(state);\n }\n\n @Method()\n async setFocus() {\n this.controller.setFocus();\n }\n\n private get isDisabled(): boolean {\n return this.disabled || this.isInactive || this.formDisabled;\n }\n\n private normalizeValueForMode(value: SelectValue): SelectValue {\n if (this.multiple) {\n return Array.isArray(value) ? [...value] : value ? [value] : [];\n }\n return Array.isArray(value) ? (value[0] ?? '') : value;\n }\n\n private get allSections(): SelectSection[] {\n return resolveChoiceSections(this.options, this.sections);\n }\n\n private get allOptions(): SelectOption[] {\n return flattenChoiceSections(this.allSections);\n }\n\n private get visibleSections(): SelectSection[] {\n return this.searchable\n ? filterChoiceSections(this.allSections, this.searchTerm)\n : this.allSections;\n }\n\n private get visibleOptions(): SelectOption[] {\n return flattenChoiceSections(this.visibleSections);\n }\n\n private get scalarValue(): string {\n return typeof this.value === 'string' ? this.value : '';\n }\n\n private get resolvedValues(): string[] {\n if (!this.multiple || !Array.isArray(this.value)) return [];\n const valid = new Set(this.allOptions.map(option => option.value));\n return [...new Set(this.value.filter(value => valid.has(value)))];\n }\n\n private get selectedOption(): SelectOption | undefined {\n if (this.multiple) return undefined;\n return this.allOptions.find(option => option.value === this.scalarValue);\n }\n\n private get hasSelection(): boolean {\n return this.multiple ? this.resolvedValues.length > 0 : Boolean(this.selectedOption);\n }\n\n private get activeOptionId(): string | undefined {\n return this.controller.activeOptionId;\n }\n\n private get popupRole(): 'listbox' | 'grid' {\n return this.allOptions.some(option => Boolean(option.action)) ? 'grid' : 'listbox';\n }\n\n private observeFooterContent() {\n const content =\n this.open && this.multiple && this.hasSelection ? this.footerContentElement : undefined;\n if (!content?.isConnected) {\n this.footerResizeObserver?.disconnect();\n this.observedFooterContentElement = undefined;\n return;\n }\n if (this.observedFooterContentElement === content) return;\n\n this.footerResizeObserver?.disconnect();\n if (typeof ResizeObserver !== 'undefined') {\n this.footerResizeObserver = new ResizeObserver(() => this.scheduleFooterSummaryMeasurement());\n this.footerResizeObserver.observe(content);\n }\n this.observedFooterContentElement = content;\n }\n\n private scheduleFooterSummaryMeasurement() {\n if (\n !this.open ||\n !this.multiple ||\n !this.hasSelection ||\n this.footerMeasurementFrame !== undefined\n ) {\n return;\n }\n this.footerMeasurementFrame = requestAnimationFrame(() => {\n this.footerMeasurementFrame = undefined;\n this.measureFooterSummary();\n });\n }\n\n private measureFooterSummary() {\n const content = this.footerContentElement;\n const summary = this.footerSummaryMeasureElement;\n const clear = this.footerClearElement;\n if (!content?.isConnected || !summary?.isConnected || !clear?.isConnected) return;\n\n const styles = getComputedStyle(content);\n const innerWidth =\n content.clientWidth -\n Number.parseFloat(styles.paddingInlineStart || '0') -\n Number.parseFloat(styles.paddingInlineEnd || '0');\n const requiredWidth =\n summary.getBoundingClientRect().width +\n clear.getBoundingClientRect().width +\n Number.parseFloat(styles.columnGap || '0');\n const nextCompact = requiredWidth > innerWidth + 0.5;\n if (nextCompact !== this.compactFooterSummary) {\n this.compactFooterSummary = nextCompact;\n }\n }\n\n private closePopup(restoreFocus = false) {\n this.controller.closePopup(restoreFocus);\n }\n\n private openPopup(focusVisible: boolean, edge?: 'first' | 'last') {\n this.controller.openPopup(focusVisible, edge);\n }\n\n private selectOption(option: SelectOption) {\n if (option.isInactive || this.isLoading) return;\n if (this.multiple) {\n const current = this.resolvedValues;\n const next = current.includes(option.value)\n ? current.filter(value => value !== option.value)\n : [...current, option.value];\n this.value = next;\n this.dsChange.emit([...next]);\n return;\n }\n this.value = option.value;\n this.dsChange.emit(option.value);\n this.controller.closePopup(true);\n }\n\n private clearSelection = (event: Event) => {\n event.preventDefault();\n event.stopPropagation();\n this.value = this.multiple ? [] : '';\n this.dsChange.emit(this.multiple ? [] : '');\n this.dsClear.emit();\n if (!this.multiple) {\n const enabled = enabledChoiceIndexes(this.visibleOptions);\n this.activeIndex = enabled[0] ?? -1;\n }\n this.controller.focusSearchOrTrigger();\n };\n\n private activateFooterAction = (event: MouseEvent) => {\n event.preventDefault();\n event.stopPropagation();\n this.dsFooterAction.emit();\n this.closePopup();\n };\n\n private readonly handleListKeyDown = this.controller.handleListKeyDown;\n private readonly handleTriggerKeyDown = this.controller.handleTriggerKeyDown;\n\n private renderOption(\n option: SelectOption,\n index: number,\n usesIcons: boolean,\n usesSubtext: boolean\n ) {\n const selected = this.multiple\n ? this.resolvedValues.includes(option.value)\n : option.value === this.scalarValue;\n const active = index === this.activeIndex;\n const actionId = `${this.generatedId}-option-action-${index}`;\n return (\n <ChoiceOptionRow\n size={this.size}\n id={`${this.generatedId}-option-${index}`}\n option={option}\n selected={selected}\n active={active}\n focusRingVisible={this.focusRingVisible}\n usesSubtext={usesSubtext}\n actionOpen={option.action?.expanded}\n popupRole={this.popupRole}\n leading={\n this.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\n class=\"select-option__checkbox\"\n label=\"\"\n size={this.size}\n checked={selected}\n presentation\n />\n </span>\n ) : usesIcons && option.icon ? (\n <span\n class=\"ds-choice-item__icon ds-control-icon-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n <ds-icon name={option.icon} size={this.size} color=\"inherit\" />\n </span>\n ) : undefined\n }\n supporting={\n option.subtextActions?.length ? (\n <span class=\"ds-choice-item__subtext ds-control-label-box select-option__subtext-actions\">\n {option.subtextActions.map((action, actionIndex) => [\n actionIndex > 0 ? (\n <ds-text\n class=\"select-option__subtext-action-separator\"\n as=\"span\"\n variant={CONTROL_SUPPORTING_TEXT_VARIANT[this.size]}\n color=\"inherit\"\n aria-hidden=\"true\"\n >\n ·\n </ds-text>\n ) : null,\n <button\n type=\"button\"\n class={{\n 'select-option__subtext-action': true,\n 'select-option__subtext-action--negative': action.tone === 'negative',\n 'ds-text-action': true,\n 'ds-focus-ring': true,\n }}\n onKeyDown={(event: KeyboardEvent) => event.stopPropagation()}\n onClick={(event: MouseEvent) => {\n event.preventDefault();\n event.stopPropagation();\n this.dsOptionSubtextAction.emit({\n value: option.value,\n actionValue: action.value,\n originalEvent: event,\n });\n }}\n >\n <ds-text\n as=\"span\"\n variant={CONTROL_SUPPORTING_TEXT_VARIANT[this.size]}\n color=\"inherit\"\n >\n {action.label}\n </ds-text>\n </button>,\n ])}\n </span>\n ) : undefined\n }\n action={\n option.action ? (\n <ds-tooltip label={option.action.label} side=\"left\" size=\"sm\">\n <ds-button-unfilled\n id={actionId}\n variant=\"icon\"\n size={OPTION_ACTION_SIZE[this.size]}\n icon=\"Ellipses\"\n rounded\n hasBorder={false}\n activeFill={false}\n ariaLabel={option.action.label}\n haspopup=\"menu\"\n controls={option.action.controls}\n expanded={option.action.expanded}\n onKeyDown={event => {\n event.stopPropagation();\n if (event.key === 'Escape' || event.key === 'ArrowLeft') {\n event.preventDefault();\n this.controller.focusSearchOrTrigger();\n }\n }}\n onDsClick={(event: CustomEvent<MouseEvent>) => {\n event.stopPropagation();\n this.dsOptionAction.emit({\n value: option.value,\n anchorId: actionId,\n originalEvent: event.detail,\n });\n }}\n />\n </ds-tooltip>\n ) : undefined\n }\n onHover={() => {\n this.focusRingVisible = false;\n this.activeIndex = index;\n }}\n onSelect={() => this.selectOption(option)}\n />\n );\n }\n\n private get captionIconOnly(): boolean {\n return this.collapseLabel && this.captionCompact && Boolean(this.icon || this.isLoading);\n }\n\n private syncCaptionCompactObserver(): void {\n this.disconnectCaptionCompactObserver();\n if (!this.collapseLabel) {\n if (this.captionCompact) this.captionCompact = false;\n return;\n }\n this.captionCompactDisconnect = observeTableCaptionCompact(this.el, compact => {\n if (this.captionCompact !== compact) this.captionCompact = compact;\n });\n }\n\n private disconnectCaptionCompactObserver(): void {\n this.captionCompactDisconnect?.();\n this.captionCompactDisconnect = undefined;\n }\n\n render() {\n const inactive = this.isDisabled;\n const showPlaceholder = !this.hasSelection;\n const count = this.resolvedValues.length;\n const label = this.multiple\n ? `${this.placeholder}${count > 0 ? ` · ${count}` : ''}`\n : showPlaceholder\n ? this.placeholder\n : (this.triggerLabel ?? this.selectedOption?.label);\n const textVariant = CONTROL_TEXT_VARIANT[this.size];\n const iconSize = ICON_SIZE[this.size];\n const usesOptionIcons = !this.multiple && choiceListUsesIcons(this.allOptions);\n const usesOptionSubtext = choiceListUsesSubtext(this.allOptions);\n const showError = this.error && Boolean(this.errorMessage);\n const describedBy =\n [this.ariaDescribedby, showError ? this.errorId : undefined].filter(Boolean).join(' ') ||\n undefined;\n const popupStyle = {\n position: 'fixed',\n left: '0',\n top: '0',\n transform: `translate(${Math.round(this.position.x)}px, ${Math.round(this.position.y)}px)`,\n zIndex: 'var(--dimension-z-index-floating)',\n visibility: this.positionReady ? 'visible' : 'hidden',\n };\n let flatIndex = 0;\n\n return (\n <Host\n class={{\n 'select-host': true,\n 'ds-select-trigger-host': true,\n 'select-host--multiple': this.multiple,\n 'ds-field-stack': true,\n 'ds-control-inactive': inactive,\n [`ds-control--${this.size}`]: true,\n 'ds-table-caption-control': this.collapseLabel,\n 'ds-table-caption-control--compact': this.captionIconOnly,\n ...controlWidthClass(this.width),\n [`select-host--background-${this.background}`]: !!this.background,\n [`ds-select-trigger-host--background-${this.background}`]: !!this.background,\n }}\n >\n <button\n ref={element => {\n this.controller.setTriggerElement((element as HTMLButtonElement) ?? null);\n }}\n id={this.inputId ?? this.generatedId}\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': !inactive && this.activeFill && this.hasSelection,\n 'trigger--expanded': !inactive && this.open,\n 'trigger--bordered': this.hasBorder,\n 'trigger--placeholder': showPlaceholder && !this.multiple,\n 'trigger--has-value': this.hasSelection,\n 'trigger--label-placeholder':\n !this.multiple && Boolean(this.triggerLabel) && this.triggerLabelPlaceholder,\n 'wrapper--error': this.hasBorder && this.error,\n [`ds-control--${this.size}`]: true,\n ...choiceBackgroundClassMap(this.background),\n }}\n disabled={inactive}\n role=\"combobox\"\n aria-haspopup={this.popupRole}\n aria-expanded={String(this.open)}\n aria-controls={this.open ? this.listboxId : undefined}\n aria-activedescendant={this.open && !this.searchable ? this.activeOptionId : undefined}\n aria-label={this.ariaLabel ?? (this.captionIconOnly ? label : undefined)}\n aria-labelledby={this.ariaLabelledby}\n aria-describedby={describedBy}\n aria-invalid={this.error ? 'true' : undefined}\n aria-required={this.required || undefined}\n aria-busy={this.isLoading ? 'true' : undefined}\n onClick={() => (this.open ? this.closePopup() : this.openPopup(false))}\n onKeyDown={event => {\n const action = this.visibleOptions[this.activeIndex]?.action;\n if (\n this.open &&\n action &&\n (event.key === 'ArrowRight' || (event.key === 'F10' && event.shiftKey))\n ) {\n event.preventDefault();\n document\n .getElementById(`${this.generatedId}-option-action-${this.activeIndex}`)\n ?.querySelector<HTMLElement>('button')\n ?.focus();\n return;\n }\n this.handleTriggerKeyDown(event);\n }}\n >\n {(this.icon || this.isLoading) && (\n <span\n class=\"trigger__prefix ds-control-icon-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n {this.isLoading ? (\n <ds-loader size={iconSize} color=\"inherit\" />\n ) : (\n <ds-icon name={this.icon} size={iconSize} color=\"inherit\" />\n )}\n </span>\n )}\n {this.captionIconOnly ? null : (\n <span\n class={{\n 'trigger__label-box': true,\n 'ds-interaction-fill__content': true,\n }}\n >\n <span\n class={{\n 'trigger__label-content': true,\n 'ds-control-label-box': true,\n 'ds-control-label-dot': this.dot,\n 'trigger__label-content--dot': this.dot,\n }}\n >\n <ds-text\n class=\"trigger__label\"\n as=\"span\"\n variant={textVariant}\n color=\"inherit\"\n lineTruncation={1}\n >\n {label}\n </ds-text>\n {this.dot && (\n <ds-badge\n class=\"trigger__dot ds-control-label-dot__badge\"\n variant=\"dot\"\n hasRing={false}\n label=\"\"\n aria-hidden=\"true\"\n />\n )}\n </span>\n </span>\n )}\n {this.captionIconOnly ? null : (\n <span\n class=\"trigger__chevron ds-control-icon-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n <ds-icon\n name={this.indicator === 'up-down' ? 'ChevronUpDown' : 'ChevronDown'}\n size={iconSize}\n color=\"inherit\"\n />\n </span>\n )}\n </button>\n\n {this.open && (\n <div\n popover=\"manual\"\n ref={element => {\n this.controller.setPopupElement((element as HTMLDivElement) ?? null);\n }}\n class=\"select-popup ds-choice-popup\"\n style={popupStyle}\n >\n {this.searchable && (\n <ChoiceSearch\n size={this.size}\n value={this.searchTerm}\n placeholder={this.searchPlaceholder}\n controls={this.listboxId}\n activeDescendant={this.activeOptionId}\n inputRef={this.controller.setSearchElement}\n clearLabel={this.clearLabel}\n onValueChange={value => {\n this.searchTerm = value;\n }}\n onKeyDown={event => this.handleListKeyDown(event)}\n />\n )}\n <div\n id={this.listboxId}\n class=\"ds-choice-list\"\n role={this.popupRole}\n aria-multiselectable={this.multiple ? 'true' : undefined}\n aria-label={this.ariaLabel ?? this.placeholder}\n aria-busy={this.isLoading ? 'true' : undefined}\n >\n {this.isLoading ? (\n <div\n class=\"ds-choice-loading ds-empty-region\"\n role={this.popupRole === 'grid' ? 'row' : 'option'}\n aria-selected=\"false\"\n aria-disabled=\"true\"\n aria-label={this.loadingLabel}\n aria-live=\"polite\"\n >\n {this.popupRole === 'grid' ? (\n <span role=\"gridcell\">\n <ds-loader size={this.size} color=\"inherit\" />\n </span>\n ) : (\n <ds-loader size={this.size} color=\"inherit\" />\n )}\n </div>\n ) : this.visibleOptions.length === 0 ? (\n <div\n class=\"ds-choice-empty ds-empty-region\"\n role={this.popupRole === 'grid' ? 'row' : 'option'}\n aria-selected=\"false\"\n aria-disabled=\"true\"\n aria-label={this.noResultsText}\n aria-live=\"polite\"\n >\n {this.popupRole === 'grid' ? (\n <span role=\"gridcell\">\n <ds-empty-state body={this.noResultsText} />\n </span>\n ) : (\n <ds-empty-state body={this.noResultsText} />\n )}\n </div>\n ) : (\n this.visibleSections.map((section, sectionIndex) => (\n <div\n class={{\n 'ds-choice-section': true,\n 'ds-chrome-column': true,\n 'ds-chrome-space--sm': true,\n 'ds-choice-section--divided': !!section.divider,\n 'ds-choice-section--headed-after-first':\n sectionIndex > 0 && Boolean(section.header),\n }}\n role={\n this.popupRole === 'grid' ? 'rowgroup' : section.header ? 'group' : undefined\n }\n aria-label={section.header}\n >\n {section.header && this.popupRole === 'grid' ? (\n <ds-text\n class={`ds-choice-section__header ds-control-section-heading ds-control--${this.size}`}\n as=\"span\"\n variant={CONTROL_SUPPORTING_TEXT_VARIANT[this.size]}\n emphasis\n color=\"primary\"\n role=\"row\"\n >\n <span class=\"ds-choice-section__header-label\" role=\"gridcell\">\n {section.header}\n </span>\n </ds-text>\n ) : section.header ? (\n <ds-text\n class={`ds-choice-section__header ds-control-section-heading ds-control--${this.size}`}\n as=\"span\"\n variant={CONTROL_SUPPORTING_TEXT_VARIANT[this.size]}\n emphasis\n color=\"primary\"\n aria-hidden=\"true\"\n >\n <span class=\"ds-choice-section__header-label\">{section.header}</span>\n </ds-text>\n ) : null}\n {section.options.map(option =>\n this.renderOption(option, flatIndex++, usesOptionIcons, usesOptionSubtext)\n )}\n </div>\n ))\n )}\n </div>\n {this.footerActionLabel && !this.isLoading ? (\n <ChoiceActionFooter\n size={this.size}\n label={this.footerActionLabel}\n onAction={this.activateFooterAction}\n />\n ) : this.allowClear && this.hasSelection && !this.isLoading ? (\n <ChoiceFooter\n size={this.size}\n summary={this.multiple ? `${count} ${this.selectedLabel}` : undefined}\n compactSummary={this.multiple ? String(count) : undefined}\n useCompactSummary={this.compactFooterSummary}\n contentRef={element => {\n this.footerContentElement = element ?? undefined;\n }}\n summaryMeasureRef={element => {\n this.footerSummaryMeasureElement = element ?? undefined;\n }}\n clearRef={element => {\n this.footerClearElement = element ?? undefined;\n }}\n clearLabel={this.clearLabel}\n onClear={this.clearSelection}\n />\n ) : null}\n </div>\n )}\n\n {showError && (\n <ds-text\n class=\"error-text\"\n as=\"div\"\n variant=\"text-body-small\"\n color=\"negative\"\n textId={this.errorId}\n role=\"alert\"\n >\n {this.errorMessage}\n </ds-text>\n )}\n </Host>\n );\n }\n}\n",
942
961
  "type": "registry:ui"
943
962
  }
944
963
  ]
@@ -43,8 +43,8 @@
43
43
  "The default view is a non-editable template. Application-owned custom views appear in a separate section with Rename and Remove actions. New view opens the naming dialog. When an editable selected view is dirty, that view row shows Save and negative Discard supporting actions while the footer remains dedicated to New view. The application decides which table state to capture, restore, and persist."
44
44
  ],
45
45
  "responsiveBehavior": [
46
- "The control remains a single toolbar item and relies on Select, Menu, and Modal collision and viewport behavior. Its choice popup keeps the token-backed 200px menu-width-xs minimum.",
47
- "Place it in the leading region of ds-table-toolbar so narrow table headers preserve the expected control order."
46
+ "The control remains a single toolbar item and relies on Select, Menu, and Modal collision and viewport behavior. Its trigger is capped at the compact menu-width token so long view names truncate while the dirty dot stays visible. Its choice popup keeps that same token as the minimum width.",
47
+ "Place it in the start region of ds-table-toolbar so the middle region can keep left-side and right-side controls on one spanning cluster."
48
48
  ],
49
49
  "frameworkCaveats": {
50
50
  "customElements": [
@@ -371,12 +371,12 @@
371
371
  "files": [
372
372
  {
373
373
  "path": "src/wc/components/TableSavedViews/TableSavedViews.css",
374
- "content": ":host {\n --ds-choice-popup-min-inline-size: var(--dimension-menu-width-xs);\n\n display: inline-block;\n min-inline-size: 0;\n}\n\nds-select {\n display: block;\n}\n\n.table-saved-views__dialog-actions {\n display: flex;\n align-items: center;\n justify-content: flex-start;\n gap: var(--dimension-space-100);\n}\n",
374
+ "content": ":host {\n --ds-choice-popup-min-inline-size: var(--dimension-menu-width-xs);\n\n display: inline-block;\n min-inline-size: 0;\n max-inline-size: var(--dimension-menu-width-xs);\n}\n\nds-select {\n display: block;\n min-inline-size: 0;\n max-inline-size: 100%;\n}\n\n.table-saved-views__dialog-actions {\n display: flex;\n align-items: center;\n justify-content: flex-start;\n gap: var(--dimension-space-100);\n}\n",
375
375
  "type": "registry:ui"
376
376
  },
377
377
  {
378
378
  "path": "src/wc/components/TableSavedViews/TableSavedViews.tsx",
379
- "content": "import { Component, Element, Event, EventEmitter, h, Host, Prop, State } from '@stencil/core';\nimport type { MenuItemData } from '../Menu/menu-types';\nimport type {\n SelectOptionActionDetail,\n SelectOptionSubtextActionDetail,\n SelectValue,\n} from '../Select/Select';\nimport type {\n TableSavedView,\n TableSavedViewChangeDetail,\n TableSavedViewCreateDetail,\n TableSavedViewDiscardDetail,\n TableSavedViewRemoveDetail,\n TableSavedViewRenameDetail,\n TableSavedViewSaveDetail,\n} from './table-saved-views-types';\n\ntype SavedViewDialogMode = 'create' | 'rename';\n\nlet tableSavedViewsId = 0;\n\n@Component({\n tag: 'ds-table-saved-views',\n styleUrl: 'TableSavedViews.css',\n scoped: true,\n})\nexport class TableSavedViews {\n @Element() el!: HTMLElement;\n\n /** Application-owned custom saved views. Assign arrays through the JavaScript property. */\n @Prop() views: TableSavedView[] = [];\n /** ID of the controlled active view, including the default view ID. */\n @Prop() value: string = '__default__';\n /** Whether the current table state differs from the selected custom view. Ignored for the default view. */\n @Prop() dirty: boolean = false;\n /** ID used for the built-in default view. */\n @Prop() defaultViewId: string = '__default__';\n /** Label used for the built-in default view. */\n @Prop() defaultViewLabel: string = 'Default';\n /** Accessible name for the saved-views select. */\n @Prop() label: string = 'Saved views';\n /** Trigger label shown while the default view is selected. */\n @Prop() triggerLabel: string = 'Views';\n /** Footer action label that opens the create-view dialog. */\n @Prop() createLabel: string = 'New view';\n\n /** Emitted when a view selection is requested. */\n @Event() dsViewChange!: EventEmitter<TableSavedViewChangeDetail>;\n /** Emitted after a valid create-view name is submitted. */\n @Event() dsViewCreate!: EventEmitter<TableSavedViewCreateDetail>;\n /** Emitted after a valid renamed view name is submitted. */\n @Event() dsViewRename!: EventEmitter<TableSavedViewRenameDetail>;\n /** Emitted when a custom view removal is requested. */\n @Event() dsViewRemove!: EventEmitter<TableSavedViewRemoveDetail>;\n /** Emitted when changes to the active custom view should replace its stored payload. */\n @Event() dsViewSave!: EventEmitter<TableSavedViewSaveDetail>;\n /** Emitted when the active custom view's stored payload should be restored. */\n @Event() dsViewDiscard!: EventEmitter<TableSavedViewDiscardDetail>;\n\n @State() private actionViewId: string | null = null;\n @State() private actionAnchorId: string | undefined;\n @State() private actionMenuOpen = false;\n @State() private actionMenuInitialFocusVisible = false;\n @State() private dialogOpen = false;\n @State() private dialogMode: SavedViewDialogMode = 'create';\n @State() private dialogViewId: string | null = null;\n @State() private nameDraft = '';\n @State() private nameError = '';\n\n private readonly componentId = `ds-table-saved-views-${++tableSavedViewsId}`;\n private readonly selectId = `${this.componentId}-select`;\n private readonly actionMenuId = `${this.componentId}-actions`;\n private readonly fieldId = `${this.componentId}-name-field`;\n private readonly inputId = `${this.componentId}-name-input`;\n private readonly actionItems: MenuItemData[] = [\n { label: 'Rename', value: 'rename' },\n { label: 'Remove', value: 'remove', isDestructive: true },\n ];\n\n private get selectedCustomView(): TableSavedView | undefined {\n return this.views.find(view => view.id === this.value);\n }\n\n private get dialogView(): TableSavedView | undefined {\n return this.views.find(view => view.id === this.dialogViewId);\n }\n\n private get sections() {\n const custom = this.views.map(view => ({\n label: view.label,\n value: view.id,\n subtextActions:\n this.dirty && view.id === this.selectedCustomView?.id\n ? [\n { label: 'Save', value: 'save' },\n { label: 'Discard', value: 'discard', tone: 'negative' as const },\n ]\n : undefined,\n action: {\n label: `Options for ${view.label}`,\n controls: this.actionMenuId,\n expanded: this.actionViewId === view.id && this.actionMenuOpen,\n },\n }));\n const templates = {\n header: 'Templates',\n options: [{ label: this.defaultViewLabel, value: this.defaultViewId }],\n divider: custom.length > 0,\n };\n return custom.length > 0 ? [templates, { header: 'Custom', options: custom }] : [templates];\n }\n\n private handleViewChange(value: SelectValue) {\n if (typeof value === 'string') this.dsViewChange.emit({ viewId: value });\n }\n\n private handleSubtextAction(detail: SelectOptionSubtextActionDetail) {\n const view = this.selectedCustomView;\n if (!view || !this.dirty || detail.value !== view.id) return;\n if (detail.actionValue === 'save') this.dsViewSave.emit({ viewId: view.id });\n if (detail.actionValue === 'discard') this.dsViewDiscard.emit({ viewId: view.id });\n }\n\n private handleOptionAction(detail: SelectOptionActionDetail) {\n if (!this.views.some(view => view.id === detail.value)) return;\n this.actionMenuInitialFocusVisible = detail.originalEvent.detail === 0;\n if (this.actionViewId === detail.value && this.actionMenuOpen) {\n this.actionMenuOpen = false;\n return;\n }\n this.actionViewId = detail.value;\n this.actionAnchorId = detail.anchorId;\n this.actionMenuOpen = true;\n }\n\n private handleActionSelect(event: CustomEvent<MenuItemData>) {\n const viewId = this.actionViewId;\n this.actionMenuOpen = false;\n if (!viewId) return;\n if (event.detail.value === 'rename') this.openDialog('rename', viewId);\n if (event.detail.value === 'remove') this.dsViewRemove.emit({ viewId });\n }\n\n private openDialog(mode: SavedViewDialogMode, viewId: string | null = null) {\n const view = mode === 'rename' ? this.views.find(item => item.id === viewId) : undefined;\n if (mode === 'rename' && !view) return;\n this.dialogMode = mode;\n this.dialogViewId = view?.id ?? null;\n this.nameDraft = view?.label ?? '';\n this.nameError = '';\n this.dialogOpen = true;\n requestAnimationFrame(() => {\n requestAnimationFrame(() => {\n const input = this.el.querySelector<HTMLElement & { setFocus?: () => Promise<void> }>(\n `#${this.inputId}`\n );\n void input?.setFocus?.();\n });\n });\n }\n\n private validateName(): string {\n const name = this.nameDraft.trim();\n if (!name) return 'View name is required.';\n const duplicate = this.views.some(\n view =>\n view.id !== this.dialogViewId &&\n view.label.trim().toLocaleLowerCase() === name.toLocaleLowerCase()\n );\n if (\n duplicate ||\n this.defaultViewLabel.trim().toLocaleLowerCase() === name.toLocaleLowerCase()\n ) {\n return 'A view with this name already exists.';\n }\n return '';\n }\n\n private submitDialog() {\n const error = this.validateName();\n if (error) {\n this.nameError = error;\n return;\n }\n const name = this.nameDraft.trim();\n if (this.dialogMode === 'rename' && this.dialogView) {\n this.dsViewRename.emit({ viewId: this.dialogView.id, name });\n } else {\n this.dsViewCreate.emit({ name });\n }\n this.dialogOpen = false;\n }\n\n private finishDialogClose() {\n if (this.dialogOpen) return;\n this.dialogViewId = null;\n this.nameDraft = '';\n this.nameError = '';\n const select = this.el.querySelector<HTMLElement & { setFocus?: () => Promise<void> }>(\n `#${this.selectId}`\n );\n void select?.setFocus?.();\n }\n\n render() {\n return (\n <Host>\n <ds-select\n id={this.selectId}\n size=\"md\"\n placeholder={this.label}\n aria-label={this.label}\n sections={this.sections}\n value={this.value}\n triggerLabel={this.selectedCustomView ? undefined : this.triggerLabel}\n triggerLabelPlaceholder={!this.selectedCustomView}\n dot={this.dirty && Boolean(this.selectedCustomView)}\n footerActionLabel={this.createLabel}\n activeFill={false}\n allowClear={false}\n onDsChange={(event: CustomEvent<SelectValue>) => this.handleViewChange(event.detail)}\n onDsFooterAction={() => this.openDialog('create')}\n onDsOptionSubtextAction={(event: CustomEvent<SelectOptionSubtextActionDetail>) =>\n this.handleSubtextAction(event.detail)\n }\n onDsOptionAction={(event: CustomEvent<SelectOptionActionDetail>) =>\n this.handleOptionAction(event.detail)\n }\n />\n\n <ds-menu\n id={this.actionMenuId}\n anchorId={this.actionAnchorId}\n menuLabel=\"Saved view options\"\n side=\"bottom\"\n align=\"end\"\n menuWidth=\"max-content\"\n minWidth=\"0\"\n items={this.actionItems}\n open={this.actionMenuOpen}\n initialFocusVisible={this.actionMenuInitialFocusVisible}\n onDsClose={() => (this.actionMenuOpen = false)}\n onDsAfterClose={() => {\n if (this.actionMenuOpen) return;\n this.actionViewId = null;\n this.actionAnchorId = undefined;\n }}\n onDsSelect={(event: CustomEvent<MenuItemData>) => this.handleActionSelect(event)}\n />\n\n <ds-modal\n open={this.dialogOpen}\n heading={this.dialogMode === 'rename' ? 'Rename view' : 'Save as new view'}\n modalWidth=\"sm\"\n onDsClose={() => (this.dialogOpen = false)}\n onDsAfterClose={() => this.finishDialogClose()}\n >\n <ds-field\n label=\"Name\"\n fieldId={this.fieldId}\n error={this.nameError.length > 0}\n errorMessage={this.nameError}\n >\n <ds-input\n id={this.inputId}\n size=\"md\"\n placeholder=\"Enter a unique name\"\n value={this.nameDraft}\n autoFocus={true}\n onDsChange={(event: CustomEvent<string>) => {\n this.nameDraft = event.detail;\n if (this.nameError) this.nameError = '';\n }}\n onKeyDown={(event: KeyboardEvent) => {\n if (event.key === 'Enter') this.submitDialog();\n }}\n />\n </ds-field>\n\n <div slot=\"footer\" class=\"table-saved-views__dialog-actions\">\n <ds-button-filled\n label={this.dialogMode === 'rename' ? 'Rename' : 'Save'}\n size=\"md\"\n onDsClick={() => this.submitDialog()}\n />\n <ds-button-unfilled\n label=\"Cancel\"\n size=\"md\"\n onDsClick={() => (this.dialogOpen = false)}\n />\n </div>\n </ds-modal>\n </Host>\n );\n }\n}\n",
379
+ "content": "import { Component, Element, Event, EventEmitter, h, Host, Prop, State } from '@stencil/core';\nimport type { MenuItemData } from '../Menu/menu-types';\nimport type {\n SelectOptionActionDetail,\n SelectOptionSubtextActionDetail,\n SelectValue,\n} from '../Select/Select';\nimport type {\n TableSavedView,\n TableSavedViewChangeDetail,\n TableSavedViewCreateDetail,\n TableSavedViewDiscardDetail,\n TableSavedViewRemoveDetail,\n TableSavedViewRenameDetail,\n TableSavedViewSaveDetail,\n} from './table-saved-views-types';\n\ntype SavedViewDialogMode = 'create' | 'rename';\n\nlet tableSavedViewsId = 0;\n\n@Component({\n tag: 'ds-table-saved-views',\n styleUrl: 'TableSavedViews.css',\n scoped: true,\n})\nexport class TableSavedViews {\n @Element() el!: HTMLElement;\n\n /** Application-owned custom saved views. Assign arrays through the JavaScript property. */\n @Prop() views: TableSavedView[] = [];\n /** ID of the controlled active view, including the default view ID. */\n @Prop() value: string = '__default__';\n /** Whether the current table state differs from the selected custom view. Ignored for the default view. */\n @Prop() dirty: boolean = false;\n /** ID used for the built-in default view. */\n @Prop() defaultViewId: string = '__default__';\n /** Label used for the built-in default view. */\n @Prop() defaultViewLabel: string = 'Default';\n /** Accessible name for the saved-views select. */\n @Prop() label: string = 'Saved views';\n /** Trigger label shown while the default view is selected. */\n @Prop() triggerLabel: string = 'Views';\n /** Footer action label that opens the create-view dialog. */\n @Prop() createLabel: string = 'New view';\n\n /** Emitted when a view selection is requested. */\n @Event() dsViewChange!: EventEmitter<TableSavedViewChangeDetail>;\n /** Emitted after a valid create-view name is submitted. */\n @Event() dsViewCreate!: EventEmitter<TableSavedViewCreateDetail>;\n /** Emitted after a valid renamed view name is submitted. */\n @Event() dsViewRename!: EventEmitter<TableSavedViewRenameDetail>;\n /** Emitted when a custom view removal is requested. */\n @Event() dsViewRemove!: EventEmitter<TableSavedViewRemoveDetail>;\n /** Emitted when changes to the active custom view should replace its stored payload. */\n @Event() dsViewSave!: EventEmitter<TableSavedViewSaveDetail>;\n /** Emitted when the active custom view's stored payload should be restored. */\n @Event() dsViewDiscard!: EventEmitter<TableSavedViewDiscardDetail>;\n\n @State() private actionViewId: string | null = null;\n @State() private actionAnchorId: string | undefined;\n @State() private actionMenuOpen = false;\n @State() private actionMenuInitialFocusVisible = false;\n @State() private dialogOpen = false;\n @State() private dialogMode: SavedViewDialogMode = 'create';\n @State() private dialogViewId: string | null = null;\n @State() private nameDraft = '';\n @State() private nameError = '';\n\n private readonly componentId = `ds-table-saved-views-${++tableSavedViewsId}`;\n private readonly selectId = `${this.componentId}-select`;\n private readonly actionMenuId = `${this.componentId}-actions`;\n private readonly fieldId = `${this.componentId}-name-field`;\n private readonly inputId = `${this.componentId}-name-input`;\n private readonly actionItems: MenuItemData[] = [\n { label: 'Rename', value: 'rename' },\n { label: 'Remove', value: 'remove', isDestructive: true },\n ];\n\n private get selectedCustomView(): TableSavedView | undefined {\n return this.views.find(view => view.id === this.value);\n }\n\n private get dialogView(): TableSavedView | undefined {\n return this.views.find(view => view.id === this.dialogViewId);\n }\n\n private get sections() {\n const custom = this.views.map(view => ({\n label: view.label,\n value: view.id,\n subtextActions:\n this.dirty && view.id === this.selectedCustomView?.id\n ? [\n { label: 'Save', value: 'save' },\n { label: 'Discard', value: 'discard', tone: 'negative' as const },\n ]\n : undefined,\n action: {\n label: `Options for ${view.label}`,\n controls: this.actionMenuId,\n expanded: this.actionViewId === view.id && this.actionMenuOpen,\n },\n }));\n const templates = {\n header: 'Templates',\n options: [{ label: this.defaultViewLabel, value: this.defaultViewId }],\n divider: custom.length > 0,\n };\n return custom.length > 0 ? [templates, { header: 'Custom', options: custom }] : [templates];\n }\n\n private handleViewChange(value: SelectValue) {\n if (typeof value === 'string') this.dsViewChange.emit({ viewId: value });\n }\n\n private handleSubtextAction(detail: SelectOptionSubtextActionDetail) {\n const view = this.selectedCustomView;\n if (!view || !this.dirty || detail.value !== view.id) return;\n if (detail.actionValue === 'save') this.dsViewSave.emit({ viewId: view.id });\n if (detail.actionValue === 'discard') this.dsViewDiscard.emit({ viewId: view.id });\n }\n\n private handleOptionAction(detail: SelectOptionActionDetail) {\n if (!this.views.some(view => view.id === detail.value)) return;\n this.actionMenuInitialFocusVisible = detail.originalEvent.detail === 0;\n if (this.actionViewId === detail.value && this.actionMenuOpen) {\n this.actionMenuOpen = false;\n return;\n }\n this.actionViewId = detail.value;\n this.actionAnchorId = detail.anchorId;\n this.actionMenuOpen = true;\n }\n\n private handleActionSelect(event: CustomEvent<MenuItemData>) {\n const viewId = this.actionViewId;\n this.actionMenuOpen = false;\n if (!viewId) return;\n if (event.detail.value === 'rename') this.openDialog('rename', viewId);\n if (event.detail.value === 'remove') this.dsViewRemove.emit({ viewId });\n }\n\n private openDialog(mode: SavedViewDialogMode, viewId: string | null = null) {\n const view = mode === 'rename' ? this.views.find(item => item.id === viewId) : undefined;\n if (mode === 'rename' && !view) return;\n this.dialogMode = mode;\n this.dialogViewId = view?.id ?? null;\n this.nameDraft = view?.label ?? '';\n this.nameError = '';\n this.dialogOpen = true;\n requestAnimationFrame(() => {\n requestAnimationFrame(() => {\n const input = this.el.querySelector<HTMLElement & { setFocus?: () => Promise<void> }>(\n `#${this.inputId}`\n );\n void input?.setFocus?.();\n });\n });\n }\n\n private validateName(): string {\n const name = this.nameDraft.trim();\n if (!name) return 'View name is required.';\n const duplicate = this.views.some(\n view =>\n view.id !== this.dialogViewId &&\n view.label.trim().toLocaleLowerCase() === name.toLocaleLowerCase()\n );\n if (\n duplicate ||\n this.defaultViewLabel.trim().toLocaleLowerCase() === name.toLocaleLowerCase()\n ) {\n return 'A view with this name already exists.';\n }\n return '';\n }\n\n private submitDialog() {\n const error = this.validateName();\n if (error) {\n this.nameError = error;\n return;\n }\n const name = this.nameDraft.trim();\n if (this.dialogMode === 'rename' && this.dialogView) {\n this.dsViewRename.emit({ viewId: this.dialogView.id, name });\n } else {\n this.dsViewCreate.emit({ name });\n }\n this.dialogOpen = false;\n }\n\n private finishDialogClose() {\n if (this.dialogOpen) return;\n this.dialogViewId = null;\n this.nameDraft = '';\n this.nameError = '';\n const select = this.el.querySelector<HTMLElement & { setFocus?: () => Promise<void> }>(\n `#${this.selectId}`\n );\n void select?.setFocus?.();\n }\n\n render() {\n return (\n <Host>\n <ds-select\n id={this.selectId}\n size=\"md\"\n width=\"fill\"\n placeholder={this.label}\n aria-label={this.label}\n sections={this.sections}\n value={this.value}\n triggerLabel={this.selectedCustomView ? undefined : this.triggerLabel}\n triggerLabelPlaceholder={!this.selectedCustomView}\n dot={this.dirty && Boolean(this.selectedCustomView)}\n footerActionLabel={this.createLabel}\n activeFill={false}\n allowClear={false}\n onDsChange={(event: CustomEvent<SelectValue>) => this.handleViewChange(event.detail)}\n onDsFooterAction={() => this.openDialog('create')}\n onDsOptionSubtextAction={(event: CustomEvent<SelectOptionSubtextActionDetail>) =>\n this.handleSubtextAction(event.detail)\n }\n onDsOptionAction={(event: CustomEvent<SelectOptionActionDetail>) =>\n this.handleOptionAction(event.detail)\n }\n />\n\n <ds-menu\n id={this.actionMenuId}\n anchorId={this.actionAnchorId}\n menuLabel=\"Saved view options\"\n side=\"bottom\"\n align=\"end\"\n menuWidth=\"max-content\"\n minWidth=\"0\"\n items={this.actionItems}\n open={this.actionMenuOpen}\n initialFocusVisible={this.actionMenuInitialFocusVisible}\n onDsClose={() => (this.actionMenuOpen = false)}\n onDsAfterClose={() => {\n if (this.actionMenuOpen) return;\n this.actionViewId = null;\n this.actionAnchorId = undefined;\n }}\n onDsSelect={(event: CustomEvent<MenuItemData>) => this.handleActionSelect(event)}\n />\n\n <ds-modal\n open={this.dialogOpen}\n heading={this.dialogMode === 'rename' ? 'Rename view' : 'Save as new view'}\n modalWidth=\"sm\"\n onDsClose={() => (this.dialogOpen = false)}\n onDsAfterClose={() => this.finishDialogClose()}\n >\n <ds-field\n label=\"Name\"\n fieldId={this.fieldId}\n error={this.nameError.length > 0}\n errorMessage={this.nameError}\n >\n <ds-input\n id={this.inputId}\n size=\"md\"\n placeholder=\"Enter a unique name\"\n value={this.nameDraft}\n autoFocus={true}\n onDsChange={(event: CustomEvent<string>) => {\n this.nameDraft = event.detail;\n if (this.nameError) this.nameError = '';\n }}\n onKeyDown={(event: KeyboardEvent) => {\n if (event.key === 'Enter') this.submitDialog();\n }}\n />\n </ds-field>\n\n <div slot=\"footer\" class=\"table-saved-views__dialog-actions\">\n <ds-button-filled\n label={this.dialogMode === 'rename' ? 'Rename' : 'Save'}\n size=\"md\"\n onDsClick={() => this.submitDialog()}\n />\n <ds-button-unfilled\n label=\"Cancel\"\n size=\"md\"\n onDsClick={() => (this.dialogOpen = false)}\n />\n </div>\n </ds-modal>\n </Host>\n );\n }\n}\n",
380
380
  "type": "registry:ui"
381
381
  },
382
382
  {
@@ -0,0 +1,229 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "table-sort",
4
+ "title": "TableSort",
5
+ "description": "Toolbar companion for the table's one controlled member-row sort, with a two-section menu for the sort field and direction that mirrors header sorting.",
6
+ "type": "registry:ui",
7
+ "meta": {
8
+ "library": "@ds-mo/ui",
9
+ "distribution": "npm",
10
+ "storybook": "https://zainadeel.github.io/compomo/?path=/story/table-sort",
11
+ "source": "src/wc/components/TableSort/TableSort.tsx",
12
+ "intentStatus": "complete",
13
+ "intent": {
14
+ "audience": "general",
15
+ "status": "stable",
16
+ "summary": "Toolbar companion for the table's one controlled member-row sort, with a two-section menu for the sort field and direction that mirrors header sorting.",
17
+ "useWhen": [
18
+ "A table toolbar needs an explicit Sort control alongside Filter and Group.",
19
+ "Sortable columns or compound header segments should be choosable without relying only on column-header clicks."
20
+ ],
21
+ "avoidWhen": [
22
+ "The table has no sortable columns; the control hides itself.",
23
+ "The application expects the control to sort rows internally or persist preferences.",
24
+ "The popup should submit a form value; use Select."
25
+ ],
26
+ "alternatives": [
27
+ {
28
+ "when": "The choice is one submitted form value.",
29
+ "component": "component:ds-select",
30
+ "reason": "Select owns form-value and listbox semantics, not table sort state."
31
+ }
32
+ ],
33
+ "commonlyComposedWith": [
34
+ "component:ds-table",
35
+ "component:ds-table-toolbar",
36
+ "component:ds-filter-menu",
37
+ "component:ds-select",
38
+ "component:ds-button-unfilled",
39
+ "component:ds-menu"
40
+ ],
41
+ "patterns": [
42
+ "pattern:menu-trigger"
43
+ ],
44
+ "accessibility": [
45
+ "The trigger is an icon-label menu button named Sort table unless a product aria-label is supplied. Compact caption widths keep that accessible name after the visible label is omitted.",
46
+ "The menu has two sections, Data and Direction. Each section presents one selected row through isSelected; the menu is not a single radio group because field and direction are independent.",
47
+ "Choosing a row applies immediately and keeps the menu open so the other section can be changed. Escape and outside activation close the menu and restore trigger focus."
48
+ ],
49
+ "states": [
50
+ "columns and sort are controlled. dsSortChange reports the next TableSortState and never mutates those inputs. The same sort object is what column headers emit through the table.",
51
+ "Selecting a different data field keeps the current direction, or starts ascending when no sort exists. Selecting a direction applies it to the current field, or to the first sortable field when no sort exists. Selecting the already-active field or direction is a no-op and does not toggle the way a header click does.",
52
+ "The trigger stays Sort in resting secondary chrome. Field and direction selection live in the menu and on column headers, so the trigger never uses active primary emphasis.",
53
+ "Compound sortable headers contribute one Data row per segment. Action columns never appear."
54
+ ],
55
+ "responsiveBehavior": [
56
+ "Place the control in the trailing region of ds-table-toolbar with Filter and Group.",
57
+ "Inside a named ds-table container narrower than 900px, the trigger omits its label and chevron and becomes icon-only. The icon keeps the resting secondary foreground."
58
+ ],
59
+ "frameworkCaveats": {
60
+ "customElements": [
61
+ "Assign columns and sort as JavaScript properties. Replace sort from both dsSortChange on this control and dsSortChange on the table."
62
+ ],
63
+ "react": [
64
+ "Keep one sort state and pass it to both ds-table and ds-table-sort; update it from either event."
65
+ ],
66
+ "angular": [
67
+ "Import the generated DsTableSort adapter, bind columns and sort, and handle dsSortChange with the same owner that handles table header sorting."
68
+ ]
69
+ },
70
+ "references": [
71
+ {
72
+ "label": "Storybook review states",
73
+ "path": "src/wc/components/TableSort/TableSort.stories.ts"
74
+ },
75
+ {
76
+ "label": "Toolbar composition owner",
77
+ "path": "src/wc/components/TableToolbar/TableToolbar.agent.json"
78
+ }
79
+ ]
80
+ },
81
+ "api": {
82
+ "props": {
83
+ "ariaLabel": {
84
+ "type": "string | null",
85
+ "resolvedType": "null | string",
86
+ "attribute": "aria-label",
87
+ "default": "null",
88
+ "required": false,
89
+ "mutable": false,
90
+ "description": "Accessible name for the trigger and menu."
91
+ },
92
+ "columns": {
93
+ "type": "TableColumn[]",
94
+ "resolvedType": "TableColumn[]",
95
+ "default": "[]",
96
+ "required": false,
97
+ "mutable": false,
98
+ "description": "Catalog used to derive sortable fields, including compound header segments."
99
+ },
100
+ "sort": {
101
+ "type": "TableSortState | null",
102
+ "resolvedType": "TableSortState | null",
103
+ "default": "null",
104
+ "required": false,
105
+ "mutable": false,
106
+ "description": "Controlled table sort. Header sorting and this menu share the same value."
107
+ }
108
+ },
109
+ "events": [
110
+ {
111
+ "name": "dsSortChange",
112
+ "detail": "TableSortChangeDetail",
113
+ "bubbles": false,
114
+ "cancelable": true,
115
+ "composed": true
116
+ }
117
+ ],
118
+ "methods": [
119
+ {
120
+ "name": "setFocus",
121
+ "signature": "setFocus() => Promise<void>"
122
+ }
123
+ ],
124
+ "slots": []
125
+ },
126
+ "props": {
127
+ "ariaLabel": {
128
+ "type": "string | null",
129
+ "resolvedType": "null | string",
130
+ "attribute": "aria-label",
131
+ "default": "null",
132
+ "required": false,
133
+ "mutable": false,
134
+ "description": "Accessible name for the trigger and menu."
135
+ },
136
+ "columns": {
137
+ "type": "TableColumn[]",
138
+ "resolvedType": "TableColumn[]",
139
+ "default": "[]",
140
+ "required": false,
141
+ "mutable": false,
142
+ "description": "Catalog used to derive sortable fields, including compound header segments."
143
+ },
144
+ "sort": {
145
+ "type": "TableSortState | null",
146
+ "resolvedType": "TableSortState | null",
147
+ "default": "null",
148
+ "required": false,
149
+ "mutable": false,
150
+ "description": "Controlled table sort. Header sorting and this menu share the same value."
151
+ }
152
+ },
153
+ "events": [
154
+ {
155
+ "name": "dsSortChange",
156
+ "detail": "TableSortChangeDetail",
157
+ "bubbles": false,
158
+ "cancelable": true,
159
+ "composed": true
160
+ }
161
+ ],
162
+ "methods": [
163
+ {
164
+ "name": "setFocus",
165
+ "signature": "setFocus() => Promise<void>"
166
+ }
167
+ ],
168
+ "slots": [],
169
+ "exports": {
170
+ "customElement": "ds-table-sort",
171
+ "react": "DsTableSort",
172
+ "vue": "DsTableSort",
173
+ "angular": "DsTableSort"
174
+ },
175
+ "consumption": {
176
+ "install": "npm install @ds-mo/ui @ds-mo/tokens @ds-mo/icons",
177
+ "cssSetup": "import '@ds-mo/tokens';\nimport '@ds-mo/tokens/reset';\nimport '@ds-mo/tokens/globals';",
178
+ "customElements": {
179
+ "import": "import '@ds-mo/ui/dist/components/ds-table-sort.js';",
180
+ "example": "<ds-table-sort></ds-table-sort>"
181
+ },
182
+ "react": {
183
+ "import": "import { DsTableSort } from '@ds-mo/ui/react';",
184
+ "example": "<DsTableSort />"
185
+ },
186
+ "vue": {
187
+ "import": "import { DsTableSort } from '@ds-mo/ui/vue';",
188
+ "example": "<DsTableSort />"
189
+ },
190
+ "angular": {
191
+ "import": "import { DsTableSort } from '@ds-mo/ui/angular/ds-table-sort';",
192
+ "example": "<ds-table-sort></ds-table-sort>"
193
+ },
194
+ "complexPropertyNote": "Assign these non-primitive values as JavaScript properties: columns, sort.",
195
+ "peerDependencies": {
196
+ "required": [
197
+ "@ds-mo/tokens ^6.5.0",
198
+ "@ds-mo/icons ^7.0.0"
199
+ ],
200
+ "frameworks": "Custom Elements; React 18/19 wrappers; Vue 3 wrappers; Angular 19-22 standalone adapters."
201
+ }
202
+ }
203
+ },
204
+ "dependencies": [
205
+ "@ds-mo/ui",
206
+ "@ds-mo/tokens"
207
+ ],
208
+ "registryDependencies": [
209
+ "button-unfilled",
210
+ "menu"
211
+ ],
212
+ "files": [
213
+ {
214
+ "path": "src/wc/components/TableSort/TableSort.css",
215
+ "content": ":host {\n display: inline-flex;\n min-inline-size: 0;\n max-inline-size: var(--dimension-menu-width-xs);\n}\n\n:host([hidden]) {\n display: none;\n}\n",
216
+ "type": "registry:ui"
217
+ },
218
+ {
219
+ "path": "src/wc/components/TableSort/TableSort.tsx",
220
+ "content": "import { Component, Element, Event, EventEmitter, h, Host, Method, Prop, State } from '@stencil/core';\nimport type { MenuItemData } from '../Menu/menu-types';\nimport type { TableColumn, TableSortChangeDetail, TableSortState } from '../Table/table-types';\nimport {\n nextTableSortStateFromMenuItem,\n tableSortFields,\n tableSortMenuSections,\n tableSortStatesEqual,\n} from './table-sort-menu';\n\nlet tableSortSeq = 0;\n\n@Component({\n tag: 'ds-table-sort',\n styleUrl: 'TableSort.css',\n scoped: true,\n})\nexport class TableSort {\n @Element() private el!: HTMLElement;\n\n /** Catalog used to derive sortable fields, including compound header segments. */\n @Prop() columns: TableColumn[] = [];\n /** Controlled table sort. Header sorting and this menu share the same value. */\n @Prop() sort: TableSortState | null = null;\n /** Accessible name for the trigger and menu. */\n @Prop({ attribute: 'aria-label' }) ariaLabel: string | null = null;\n\n @Event({ bubbles: false }) dsSortChange!: EventEmitter<TableSortChangeDetail>;\n\n @State() private menuOpen = false;\n @State() private initialFocusVisible = false;\n\n private readonly componentId = `ds-table-sort-${++tableSortSeq}`;\n private readonly triggerId = `${this.componentId}-trigger`;\n private readonly menuId = `${this.componentId}-menu`;\n\n @Method()\n async setFocus() {\n const trigger = this.el.querySelector<HTMLElement & { setFocus?: () => Promise<void> }>(\n `#${CSS.escape(this.triggerId)}`\n );\n await trigger?.setFocus?.();\n }\n\n render() {\n const fields = tableSortFields(this.columns);\n const name = this.ariaLabel?.trim() || 'Sort table';\n\n return (\n <Host hidden={fields.length === 0 ? true : undefined}>\n {fields.length === 0 ? null : (\n <ds-button-unfilled\n id={this.triggerId}\n variant=\"icon-label\"\n size=\"md\"\n icon=\"ArrowsVertical\"\n label=\"Sort\"\n aria-label={name}\n hasMenu={true}\n collapseLabel={true}\n expanded={this.menuOpen}\n controls={this.menuId}\n onDsClick={(event: CustomEvent<MouseEvent>) => {\n this.toggle(event.detail.detail === 0);\n }}\n />\n )}\n {fields.length === 0 ? null : (\n <ds-menu\n id={this.menuId}\n open={this.menuOpen}\n anchorId={this.triggerId}\n align=\"end\"\n side=\"bottom\"\n menuLabel={name}\n selectionMode=\"none\"\n initialFocusVisible={this.initialFocusVisible}\n sections={tableSortMenuSections(this.columns, this.sort)}\n onDsClose={() => this.close()}\n onDsSelect={event => this.handleSelect(event.detail)}\n />\n )}\n </Host>\n );\n }\n\n private toggle(fromKeyboard = false): void {\n if (this.menuOpen) this.close();\n else this.open(fromKeyboard);\n }\n\n private open(fromKeyboard = false): void {\n if (this.menuOpen) return;\n this.initialFocusVisible = fromKeyboard;\n this.menuOpen = true;\n }\n\n private close(): void {\n this.menuOpen = false;\n }\n\n private handleSelect(item: MenuItemData): void {\n const next = nextTableSortStateFromMenuItem(this.columns, this.sort, item);\n if (tableSortStatesEqual(this.sort, next)) return;\n this.dsSortChange.emit({ sort: next });\n }\n}\n",
221
+ "type": "registry:ui"
222
+ },
223
+ {
224
+ "path": "src/wc/components/TableSort/table-sort-menu.ts",
225
+ "content": "import type { MenuItemData, MenuSection } from '../Menu/menu-types';\nimport { isTableActionColumn } from '../Table/table-column-customizer';\nimport type { TableColumn, TableSortState } from '../Table/table-types';\n\nexport const TABLE_SORT_DIRECTION_ASC = 'direction:asc';\nexport const TABLE_SORT_DIRECTION_DESC = 'direction:desc';\n\nexport interface TableSortField {\n id: string;\n label: string;\n}\n\n/** Sortable data fields from the catalog, including compound header segments. */\nexport function tableSortFields(columns: readonly TableColumn[]): TableSortField[] {\n const fields: TableSortField[] = [];\n const seen = new Set<string>();\n\n for (const column of columns) {\n if (isTableActionColumn(column) || !column.sortable) continue;\n const segments = column.headerSegments?.filter(segment => segment.sortKey.trim()) ?? [];\n if (segments.length > 0) {\n for (const segment of segments) {\n if (seen.has(segment.sortKey)) continue;\n seen.add(segment.sortKey);\n fields.push({\n id: segment.sortKey,\n label: segment.label.trim() || segment.sortKey,\n });\n }\n continue;\n }\n if (seen.has(column.id)) continue;\n seen.add(column.id);\n fields.push({\n id: column.id,\n label: column.header.trim() || column.headerLabel?.trim() || column.id,\n });\n }\n\n return fields;\n}\n\nexport function tableSortMenuSections(\n columns: readonly TableColumn[],\n sort: TableSortState | null | undefined\n): MenuSection[] {\n const fields = tableSortFields(columns);\n return [\n {\n header: 'Data',\n items: fields.map(field => ({\n label: field.label,\n value: field.id,\n isSelected: sort?.columnId === field.id,\n })),\n },\n {\n header: 'Direction',\n items: [\n {\n label: 'Ascending',\n value: TABLE_SORT_DIRECTION_ASC,\n icon: 'ArrowUp',\n isSelected: sort?.direction === 'asc',\n },\n {\n label: 'Descending',\n value: TABLE_SORT_DIRECTION_DESC,\n icon: 'ArrowDown',\n isSelected: sort?.direction === 'desc',\n },\n ],\n },\n ];\n}\n\nexport function tableSortStatesEqual(\n left: TableSortState | null | undefined,\n right: TableSortState | null | undefined\n): boolean {\n if (left === right) return true;\n if (!left || !right) return false;\n return left.columnId === right.columnId && left.direction === right.direction;\n}\n\n/** Next controlled sort from a Sort menu selection. Does not toggle like a header click. */\nexport function nextTableSortStateFromMenuItem(\n columns: readonly TableColumn[],\n current: TableSortState | null | undefined,\n item: MenuItemData\n): TableSortState | null {\n const fields = tableSortFields(columns);\n if (fields.length === 0) return current ?? null;\n\n if (item.value === TABLE_SORT_DIRECTION_ASC || item.value === TABLE_SORT_DIRECTION_DESC) {\n const direction = item.value === TABLE_SORT_DIRECTION_ASC ? 'asc' : 'desc';\n const columnId = current?.columnId ?? fields[0]!.id;\n return { columnId, direction };\n }\n\n if (!item.value || !fields.some(field => field.id === item.value)) return current ?? null;\n if (current?.columnId === item.value) return current ?? null;\n return { columnId: item.value, direction: current?.direction ?? 'asc' };\n}\n",
226
+ "type": "registry:ui"
227
+ }
228
+ ]
229
+ }