@miragon/mcp-toolkit-ui 2.7.0 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (388) hide show
  1. package/README.md +7 -4
  2. package/dist/app/adapt-data-widget.d.ts +1 -22
  3. package/dist/app/adapt-data-widget.d.ts.map +1 -1
  4. package/dist/app/adapt-data-widget.js +1 -35
  5. package/dist/app/adapt-data-widget.js.map +1 -1
  6. package/dist/app/builder/BuilderBanners.js +1 -1
  7. package/dist/app/builder/BuilderBanners.js.map +1 -1
  8. package/dist/app/builder/CanvasRow.d.ts +4 -2
  9. package/dist/app/builder/CanvasRow.d.ts.map +1 -1
  10. package/dist/app/builder/CanvasRow.js +21 -7
  11. package/dist/app/builder/CanvasRow.js.map +1 -1
  12. package/dist/app/builder/CatalogueSheet.d.ts.map +1 -1
  13. package/dist/app/builder/CatalogueSheet.js +11 -4
  14. package/dist/app/builder/CatalogueSheet.js.map +1 -1
  15. package/dist/app/builder/CellPropsSheet.d.ts +7 -3
  16. package/dist/app/builder/CellPropsSheet.d.ts.map +1 -1
  17. package/dist/app/builder/CellPropsSheet.js +10 -3
  18. package/dist/app/builder/CellPropsSheet.js.map +1 -1
  19. package/dist/app/builder/DiscardDialog.d.ts +18 -0
  20. package/dist/app/builder/DiscardDialog.d.ts.map +1 -0
  21. package/dist/app/builder/DiscardDialog.js +18 -0
  22. package/dist/app/builder/DiscardDialog.js.map +1 -0
  23. package/dist/app/builder/LayoutSurface.d.ts +33 -9
  24. package/dist/app/builder/LayoutSurface.d.ts.map +1 -1
  25. package/dist/app/builder/LayoutSurface.js +74 -23
  26. package/dist/app/builder/LayoutSurface.js.map +1 -1
  27. package/dist/app/builder/PipelineStrip.d.ts +2 -1
  28. package/dist/app/builder/PipelineStrip.d.ts.map +1 -1
  29. package/dist/app/builder/PipelineStrip.js +26 -13
  30. package/dist/app/builder/PipelineStrip.js.map +1 -1
  31. package/dist/app/builder/SaveDialog.d.ts +9 -1
  32. package/dist/app/builder/SaveDialog.d.ts.map +1 -1
  33. package/dist/app/builder/SaveDialog.js +8 -2
  34. package/dist/app/builder/SaveDialog.js.map +1 -1
  35. package/dist/app/builder/Toolbar.d.ts +12 -4
  36. package/dist/app/builder/Toolbar.d.ts.map +1 -1
  37. package/dist/app/builder/Toolbar.js +18 -7
  38. package/dist/app/builder/Toolbar.js.map +1 -1
  39. package/dist/app/builder/Workspace.d.ts +23 -3
  40. package/dist/app/builder/Workspace.d.ts.map +1 -1
  41. package/dist/app/builder/Workspace.js +58 -8
  42. package/dist/app/builder/Workspace.js.map +1 -1
  43. package/dist/app/builder/builder-model.d.ts +143 -1
  44. package/dist/app/builder/builder-model.d.ts.map +1 -1
  45. package/dist/app/builder/builder-model.js +191 -1
  46. package/dist/app/builder/builder-model.js.map +1 -1
  47. package/dist/app/builder/reveal.d.ts +10 -0
  48. package/dist/app/builder/reveal.d.ts.map +1 -0
  49. package/dist/app/builder/reveal.js +10 -0
  50. package/dist/app/builder/reveal.js.map +1 -0
  51. package/dist/app/builder/use-builder-catalogue.d.ts +4 -1
  52. package/dist/app/builder/use-builder-catalogue.d.ts.map +1 -1
  53. package/dist/app/builder/use-builder-catalogue.js +44 -17
  54. package/dist/app/builder/use-builder-catalogue.js.map +1 -1
  55. package/dist/app/builder/use-builder-session.d.ts +30 -0
  56. package/dist/app/builder/use-builder-session.d.ts.map +1 -0
  57. package/dist/app/builder/use-builder-session.js +58 -0
  58. package/dist/app/builder/use-builder-session.js.map +1 -0
  59. package/dist/app/builder/use-display-resolver.d.ts +17 -0
  60. package/dist/app/builder/use-display-resolver.d.ts.map +1 -0
  61. package/dist/app/builder/use-display-resolver.js +16 -0
  62. package/dist/app/builder/use-display-resolver.js.map +1 -0
  63. package/dist/app/index.d.ts +8 -3
  64. package/dist/app/index.d.ts.map +1 -1
  65. package/dist/app/index.js +8 -2
  66. package/dist/app/index.js.map +1 -1
  67. package/dist/app/layout-builder.d.ts +13 -25
  68. package/dist/app/layout-builder.d.ts.map +1 -1
  69. package/dist/app/layout-builder.js +75 -81
  70. package/dist/app/layout-builder.js.map +1 -1
  71. package/dist/app/mcp-app-view-chrome.d.ts +12 -3
  72. package/dist/app/mcp-app-view-chrome.d.ts.map +1 -1
  73. package/dist/app/mcp-app-view-chrome.js +21 -5
  74. package/dist/app/mcp-app-view-chrome.js.map +1 -1
  75. package/dist/app/mcp-app-view-states.d.ts +11 -3
  76. package/dist/app/mcp-app-view-states.d.ts.map +1 -1
  77. package/dist/app/mcp-app-view-states.js +29 -8
  78. package/dist/app/mcp-app-view-states.js.map +1 -1
  79. package/dist/app/mcp-app-view.d.ts +33 -1
  80. package/dist/app/mcp-app-view.d.ts.map +1 -1
  81. package/dist/app/mcp-app-view.js +181 -127
  82. package/dist/app/mcp-app-view.js.map +1 -1
  83. package/dist/app/mcp-toolkit-app.d.ts +6 -1
  84. package/dist/app/mcp-toolkit-app.d.ts.map +1 -1
  85. package/dist/app/mcp-toolkit-app.js +8 -1
  86. package/dist/app/mcp-toolkit-app.js.map +1 -1
  87. package/dist/app/mcp-use-host-bridge.d.ts +15 -6
  88. package/dist/app/mcp-use-host-bridge.d.ts.map +1 -1
  89. package/dist/app/mcp-use-host-bridge.js +51 -22
  90. package/dist/app/mcp-use-host-bridge.js.map +1 -1
  91. package/dist/app/model-context.d.ts +71 -0
  92. package/dist/app/model-context.d.ts.map +1 -0
  93. package/dist/app/model-context.js +101 -0
  94. package/dist/app/model-context.js.map +1 -0
  95. package/dist/app/use-build-mode-focus.d.ts +33 -0
  96. package/dist/app/use-build-mode-focus.d.ts.map +1 -0
  97. package/dist/app/use-build-mode-focus.js +59 -0
  98. package/dist/app/use-build-mode-focus.js.map +1 -0
  99. package/dist/app/use-view-actions.d.ts +31 -0
  100. package/dist/app/use-view-actions.d.ts.map +1 -0
  101. package/dist/app/use-view-actions.js +81 -0
  102. package/dist/app/use-view-actions.js.map +1 -0
  103. package/dist/app/use-view-lifecycle.d.ts +4 -0
  104. package/dist/app/use-view-lifecycle.d.ts.map +1 -1
  105. package/dist/app/use-view-lifecycle.js +2 -0
  106. package/dist/app/use-view-lifecycle.js.map +1 -1
  107. package/dist/app/view-lifecycle.d.ts +80 -4
  108. package/dist/app/view-lifecycle.d.ts.map +1 -1
  109. package/dist/app/view-lifecycle.js +90 -3
  110. package/dist/app/view-lifecycle.js.map +1 -1
  111. package/dist/app/widget-fixture.d.ts +10 -3
  112. package/dist/app/widget-fixture.d.ts.map +1 -1
  113. package/dist/app/widget-fixture.js +21 -8
  114. package/dist/app/widget-fixture.js.map +1 -1
  115. package/dist/app/widget-props-sheet.d.ts +3 -1
  116. package/dist/app/widget-props-sheet.d.ts.map +1 -1
  117. package/dist/app/widget-props-sheet.js +22 -10
  118. package/dist/app/widget-props-sheet.js.map +1 -1
  119. package/dist/app/widget-renderer.js +1 -1
  120. package/dist/app/widget-renderer.js.map +1 -1
  121. package/dist/components/DrillButton.d.ts +14 -6
  122. package/dist/components/DrillButton.d.ts.map +1 -1
  123. package/dist/components/DrillButton.js +14 -7
  124. package/dist/components/DrillButton.js.map +1 -1
  125. package/dist/components/EmptyState.d.ts.map +1 -1
  126. package/dist/components/EmptyState.js +4 -2
  127. package/dist/components/EmptyState.js.map +1 -1
  128. package/dist/components/ErrorState.d.ts +5 -2
  129. package/dist/components/ErrorState.d.ts.map +1 -1
  130. package/dist/components/ErrorState.js +25 -8
  131. package/dist/components/ErrorState.js.map +1 -1
  132. package/dist/components/FilterBar.d.ts +15 -4
  133. package/dist/components/FilterBar.d.ts.map +1 -1
  134. package/dist/components/FilterBar.js +11 -6
  135. package/dist/components/FilterBar.js.map +1 -1
  136. package/dist/components/KpiGrid.d.ts +66 -21
  137. package/dist/components/KpiGrid.d.ts.map +1 -1
  138. package/dist/components/KpiGrid.js +101 -34
  139. package/dist/components/KpiGrid.js.map +1 -1
  140. package/dist/components/ListFooter.d.ts +31 -4
  141. package/dist/components/ListFooter.d.ts.map +1 -1
  142. package/dist/components/ListFooter.js +30 -11
  143. package/dist/components/ListFooter.js.map +1 -1
  144. package/dist/components/PagedListFooter.d.ts +57 -0
  145. package/dist/components/PagedListFooter.d.ts.map +1 -0
  146. package/dist/components/PagedListFooter.js +27 -0
  147. package/dist/components/PagedListFooter.js.map +1 -0
  148. package/dist/components/Pills.d.ts +27 -7
  149. package/dist/components/Pills.d.ts.map +1 -1
  150. package/dist/components/Pills.js +21 -6
  151. package/dist/components/Pills.js.map +1 -1
  152. package/dist/components/SectionHeading.d.ts +3 -1
  153. package/dist/components/SectionHeading.d.ts.map +1 -1
  154. package/dist/components/SectionHeading.js +6 -2
  155. package/dist/components/SectionHeading.js.map +1 -1
  156. package/dist/components/Toast.d.ts +9 -3
  157. package/dist/components/Toast.d.ts.map +1 -1
  158. package/dist/components/Toast.js +31 -16
  159. package/dist/components/Toast.js.map +1 -1
  160. package/dist/components/WidgetHeader.d.ts +34 -10
  161. package/dist/components/WidgetHeader.d.ts.map +1 -1
  162. package/dist/components/WidgetHeader.js +11 -6
  163. package/dist/components/WidgetHeader.js.map +1 -1
  164. package/dist/hooks/index.d.ts +4 -4
  165. package/dist/hooks/index.d.ts.map +1 -1
  166. package/dist/hooks/index.js +1 -1
  167. package/dist/hooks/index.js.map +1 -1
  168. package/dist/hooks/tool-result-recovery-model.d.ts +155 -0
  169. package/dist/hooks/tool-result-recovery-model.d.ts.map +1 -0
  170. package/dist/hooks/tool-result-recovery-model.js +273 -0
  171. package/dist/hooks/tool-result-recovery-model.js.map +1 -0
  172. package/dist/hooks/use-tool-query.d.ts +56 -14
  173. package/dist/hooks/use-tool-query.d.ts.map +1 -1
  174. package/dist/hooks/use-tool-query.js +102 -25
  175. package/dist/hooks/use-tool-query.js.map +1 -1
  176. package/dist/hooks/use-tool-result-recovery.d.ts +47 -52
  177. package/dist/hooks/use-tool-result-recovery.d.ts.map +1 -1
  178. package/dist/hooks/use-tool-result-recovery.js +58 -120
  179. package/dist/hooks/use-tool-result-recovery.js.map +1 -1
  180. package/dist/hooks/use-view-data.d.ts +52 -10
  181. package/dist/hooks/use-view-data.d.ts.map +1 -1
  182. package/dist/hooks/use-view-data.js +81 -16
  183. package/dist/hooks/use-view-data.js.map +1 -1
  184. package/dist/hooks/use-view-tool-query.d.ts +12 -7
  185. package/dist/hooks/use-view-tool-query.d.ts.map +1 -1
  186. package/dist/hooks/use-view-tool-query.js +15 -14
  187. package/dist/hooks/use-view-tool-query.js.map +1 -1
  188. package/dist/index.d.ts +17 -10
  189. package/dist/index.d.ts.map +1 -1
  190. package/dist/index.js +18 -6
  191. package/dist/index.js.map +1 -1
  192. package/dist/lib/focus-return.d.ts +61 -0
  193. package/dist/lib/focus-return.d.ts.map +1 -0
  194. package/dist/lib/focus-return.js +46 -0
  195. package/dist/lib/focus-return.js.map +1 -0
  196. package/dist/lib/parse-tool-result.d.ts +23 -1
  197. package/dist/lib/parse-tool-result.d.ts.map +1 -1
  198. package/dist/lib/parse-tool-result.js +38 -2
  199. package/dist/lib/parse-tool-result.js.map +1 -1
  200. package/dist/lib/toast-model.d.ts +41 -1
  201. package/dist/lib/toast-model.d.ts.map +1 -1
  202. package/dist/lib/toast-model.js +67 -1
  203. package/dist/lib/toast-model.js.map +1 -1
  204. package/dist/lib/tone-utils.d.ts +58 -6
  205. package/dist/lib/tone-utils.d.ts.map +1 -1
  206. package/dist/lib/tone-utils.js +66 -4
  207. package/dist/lib/tone-utils.js.map +1 -1
  208. package/dist/lib/tool-query.d.ts +107 -0
  209. package/dist/lib/tool-query.d.ts.map +1 -0
  210. package/dist/lib/tool-query.js +182 -0
  211. package/dist/lib/tool-query.js.map +1 -0
  212. package/dist/lib/tool-result-observer.d.ts +108 -0
  213. package/dist/lib/tool-result-observer.d.ts.map +1 -0
  214. package/dist/lib/tool-result-observer.js +158 -0
  215. package/dist/lib/tool-result-observer.js.map +1 -0
  216. package/dist/lib/typography.d.ts +37 -0
  217. package/dist/lib/typography.d.ts.map +1 -0
  218. package/dist/lib/typography.js +41 -0
  219. package/dist/lib/typography.js.map +1 -0
  220. package/dist/lib/utils.d.ts.map +1 -1
  221. package/dist/lib/utils.js +11 -1
  222. package/dist/lib/utils.js.map +1 -1
  223. package/dist/locale/index.d.ts +1 -1
  224. package/dist/locale/index.d.ts.map +1 -1
  225. package/dist/locale/message-types.d.ts +117 -4
  226. package/dist/locale/message-types.d.ts.map +1 -1
  227. package/dist/locale/messages-de.d.ts.map +1 -1
  228. package/dist/locale/messages-de.js +44 -10
  229. package/dist/locale/messages-de.js.map +1 -1
  230. package/dist/locale/messages-en.d.ts.map +1 -1
  231. package/dist/locale/messages-en.js +44 -10
  232. package/dist/locale/messages-en.js.map +1 -1
  233. package/dist/locale/toolkit-messages.d.ts +19 -1
  234. package/dist/locale/toolkit-messages.d.ts.map +1 -1
  235. package/dist/locale/toolkit-messages.js +54 -1
  236. package/dist/locale/toolkit-messages.js.map +1 -1
  237. package/dist/primitives/alert.d.ts +1 -1
  238. package/dist/primitives/alert.d.ts.map +1 -1
  239. package/dist/primitives/alert.js +2 -0
  240. package/dist/primitives/alert.js.map +1 -1
  241. package/dist/primitives/badge.d.ts +1 -1
  242. package/dist/primitives/badge.d.ts.map +1 -1
  243. package/dist/primitives/badge.js +9 -4
  244. package/dist/primitives/badge.js.map +1 -1
  245. package/dist/primitives/button.d.ts +1 -1
  246. package/dist/primitives/button.js +1 -1
  247. package/dist/primitives/button.js.map +1 -1
  248. package/dist/primitives/dialog.d.ts +1 -1
  249. package/dist/primitives/dialog.d.ts.map +1 -1
  250. package/dist/primitives/dialog.js +10 -3
  251. package/dist/primitives/dialog.js.map +1 -1
  252. package/dist/primitives/select.js +1 -1
  253. package/dist/primitives/select.js.map +1 -1
  254. package/dist/primitives/sheet.d.ts +1 -1
  255. package/dist/primitives/sheet.d.ts.map +1 -1
  256. package/dist/primitives/sheet.js +6 -2
  257. package/dist/primitives/sheet.js.map +1 -1
  258. package/dist/providers/query-provider.d.ts +32 -1
  259. package/dist/providers/query-provider.d.ts.map +1 -1
  260. package/dist/providers/query-provider.js +53 -4
  261. package/dist/providers/query-provider.js.map +1 -1
  262. package/dist/render-cases-primitives.js +1 -1
  263. package/dist/render-cases-primitives.js.map +1 -1
  264. package/dist/render-cases.d.ts.map +1 -1
  265. package/dist/render-cases.js +30 -6
  266. package/dist/render-cases.js.map +1 -1
  267. package/dist/render-effects.d.ts +42 -0
  268. package/dist/render-effects.d.ts.map +1 -0
  269. package/dist/render-effects.js +81 -0
  270. package/dist/render-effects.js.map +1 -0
  271. package/dist/theme/create-theme.d.ts +19 -1
  272. package/dist/theme/create-theme.d.ts.map +1 -1
  273. package/dist/theme/create-theme.js +15 -0
  274. package/dist/theme/create-theme.js.map +1 -1
  275. package/package.json +5 -4
  276. package/src/app/adapt-data-widget.tsx +2 -42
  277. package/src/app/builder/BuilderBanners.tsx +1 -1
  278. package/src/app/builder/CanvasRow.tsx +63 -17
  279. package/src/app/builder/CatalogueSheet.tsx +50 -29
  280. package/src/app/builder/CellPropsSheet.tsx +17 -2
  281. package/src/app/builder/DiscardDialog.tsx +59 -0
  282. package/src/app/builder/LayoutSurface.tsx +206 -66
  283. package/src/app/builder/PipelineStrip.tsx +253 -153
  284. package/src/app/builder/SaveDialog.tsx +14 -2
  285. package/src/app/builder/Toolbar.tsx +144 -59
  286. package/src/app/builder/Workspace.tsx +226 -58
  287. package/src/app/builder/builder-model.ts +341 -2
  288. package/src/app/builder/builder-model.ux.test.ts +456 -0
  289. package/src/app/builder/builder-ux.test.tsx +844 -0
  290. package/src/app/builder/reveal.ts +10 -0
  291. package/src/app/builder/use-builder-catalogue.ts +56 -19
  292. package/src/app/builder/use-builder-session.ts +68 -0
  293. package/src/app/builder/use-display-resolver.ts +24 -0
  294. package/src/app/index.ts +12 -2
  295. package/src/app/layout-builder.tsx +178 -191
  296. package/src/app/mcp-app-view-chrome.test.tsx +141 -1
  297. package/src/app/mcp-app-view-chrome.tsx +56 -6
  298. package/src/app/mcp-app-view-states.tsx +51 -11
  299. package/src/app/mcp-app-view.effects.test.tsx +292 -0
  300. package/src/app/mcp-app-view.test.tsx +222 -2
  301. package/src/app/mcp-app-view.tsx +267 -117
  302. package/src/app/mcp-toolkit-app.test.tsx +40 -0
  303. package/src/app/mcp-toolkit-app.tsx +8 -1
  304. package/src/app/mcp-use-host-bridge.test.tsx +180 -0
  305. package/src/app/mcp-use-host-bridge.tsx +67 -24
  306. package/src/app/model-context.test.tsx +116 -0
  307. package/src/app/model-context.tsx +125 -0
  308. package/src/app/use-build-mode-focus.ts +62 -0
  309. package/src/app/use-view-actions.ts +89 -0
  310. package/src/app/use-view-lifecycle.ts +6 -0
  311. package/src/app/view-lifecycle.test.ts +216 -0
  312. package/src/app/view-lifecycle.ts +165 -5
  313. package/src/app/widget-fixture.effects.test.tsx +51 -0
  314. package/src/app/widget-fixture.render.test.tsx +81 -0
  315. package/src/app/widget-fixture.tsx +34 -11
  316. package/src/app/widget-props-sheet.tsx +57 -16
  317. package/src/app/widget-renderer.tsx +1 -1
  318. package/src/components/DrillButton.test.tsx +48 -0
  319. package/src/components/DrillButton.tsx +26 -11
  320. package/src/components/EmptyState.tsx +6 -4
  321. package/src/components/ErrorState.tsx +31 -9
  322. package/src/components/FilterBar.test.tsx +82 -0
  323. package/src/components/FilterBar.tsx +52 -26
  324. package/src/components/KpiGrid.test.tsx +279 -0
  325. package/src/components/KpiGrid.tsx +297 -90
  326. package/src/components/ListFooter.test.tsx +116 -0
  327. package/src/components/ListFooter.tsx +102 -15
  328. package/src/components/PagedListFooter.test.tsx +140 -0
  329. package/src/components/PagedListFooter.tsx +107 -0
  330. package/src/components/Pills.test.tsx +77 -0
  331. package/src/components/Pills.tsx +63 -10
  332. package/src/components/SectionHeading.tsx +9 -3
  333. package/src/components/Toast.tsx +37 -19
  334. package/src/components/WidgetHeader.test.tsx +78 -0
  335. package/src/components/WidgetHeader.tsx +63 -16
  336. package/src/components/feedback-states.test.tsx +98 -9
  337. package/src/components/tone-figures.test.tsx +8 -4
  338. package/src/hooks/index.ts +6 -3
  339. package/src/hooks/tool-result-recovery-model.test.ts +744 -0
  340. package/src/hooks/tool-result-recovery-model.ts +418 -0
  341. package/src/hooks/use-tool-query.effects.test.tsx +205 -0
  342. package/src/hooks/use-tool-query.ts +156 -33
  343. package/src/hooks/use-tool-result-recovery.effects.test.tsx +141 -0
  344. package/src/hooks/use-tool-result-recovery.test.ts +3 -4
  345. package/src/hooks/use-tool-result-recovery.ts +84 -142
  346. package/src/hooks/use-view-data.test.tsx +118 -9
  347. package/src/hooks/use-view-data.ts +128 -18
  348. package/src/hooks/use-view-tool-query.ts +25 -19
  349. package/src/i18n-coverage.test.tsx +104 -2
  350. package/src/index.ts +48 -11
  351. package/src/lib/focus-return.test.ts +288 -0
  352. package/src/lib/focus-return.ts +96 -0
  353. package/src/lib/parse-tool-result.test.ts +46 -1
  354. package/src/lib/parse-tool-result.ts +40 -2
  355. package/src/lib/toast-model.test.ts +166 -1
  356. package/src/lib/toast-model.ts +104 -2
  357. package/src/lib/tone-utils.test.ts +86 -10
  358. package/src/lib/tone-utils.ts +89 -8
  359. package/src/lib/tool-query.test.ts +569 -0
  360. package/src/lib/tool-query.ts +253 -0
  361. package/src/lib/tool-result-observer.test.ts +296 -0
  362. package/src/lib/tool-result-observer.ts +211 -0
  363. package/src/lib/typography.test.ts +45 -0
  364. package/src/lib/typography.ts +47 -0
  365. package/src/lib/utils.ts +12 -1
  366. package/src/locale/index.ts +1 -0
  367. package/src/locale/message-types.ts +118 -2
  368. package/src/locale/messages-de.ts +49 -10
  369. package/src/locale/messages-en.ts +49 -10
  370. package/src/locale/toolkit-messages.test.ts +133 -0
  371. package/src/locale/toolkit-messages.ts +63 -2
  372. package/src/primitives/alert.tsx +3 -0
  373. package/src/primitives/badge.tsx +9 -4
  374. package/src/primitives/button.tsx +1 -1
  375. package/src/primitives/dialog.tsx +10 -1
  376. package/src/primitives/select.tsx +1 -1
  377. package/src/primitives/sheet.tsx +7 -0
  378. package/src/providers/query-provider.test.tsx +154 -0
  379. package/src/providers/query-provider.tsx +66 -5
  380. package/src/render-cases-primitives.tsx +1 -1
  381. package/src/render-cases.tsx +38 -6
  382. package/src/render-effects.tsx +96 -0
  383. package/src/theme/cd-tokens.test.ts +3 -1
  384. package/src/theme/create-theme.test.ts +10 -0
  385. package/src/theme/create-theme.ts +28 -0
  386. package/src/theme/theme-css.test.ts +51 -0
  387. package/src/theme.css +31 -1
  388. package/ui-catalog.json +448 -41
@@ -1,164 +1,371 @@
1
- import type { ReactNode } from "react"
2
- import { FOCUS_RING_INSET } from "../lib/focus-ring.js"
3
- import { TONE_DOT, type ToneVariant } from "../lib/tone-utils.js"
1
+ import { useId, type ReactNode } from "react"
2
+ import { ChevronRight, Minus, TrendingDown, TrendingUp, type LucideIcon } from "lucide-react"
3
+ import { FOCUS_RING_INSET, FOCUS_RING } from "../lib/focus-ring.js"
4
+ import {
5
+ resolveTone,
6
+ TONE_BORDER,
7
+ TONE_DOT,
8
+ TONE_ICON,
9
+ TONE_TINT,
10
+ type ToneName,
11
+ type ToneVariant,
12
+ } from "../lib/tone-utils.js"
13
+ import { MICRO_LABEL } from "../lib/typography.js"
14
+ import { cn } from "../lib/utils.js"
15
+ import { Skeleton } from "../primitives/skeleton.js"
16
+
17
+ type TrendDirection = "up" | "down" | "flat"
4
18
 
5
19
  /**
6
- * Trend direction → tone. The tone shows ONLY as a dot before the trend line;
7
- * the figures stay in the foreground (no coloured digits, CI §3.3).
20
+ * Direction → tone when no `trendTone` is set (incident-biased: up reads as
21
+ * worse). The tone shows ONLY as the direction icon's colour or a dot; the
22
+ * trend words stay neutral (no coloured digits, CI §3.3).
8
23
  */
9
- const TREND_TONE: Record<"up" | "down" | "flat", ToneVariant> = {
24
+ const TREND_TONE: Record<TrendDirection, ToneVariant> = {
10
25
  up: "danger",
11
26
  down: "success",
12
27
  flat: "neutral",
13
28
  }
14
29
 
30
+ const TREND_ICON: Record<TrendDirection, LucideIcon> = {
31
+ up: TrendingUp,
32
+ down: TrendingDown,
33
+ flat: Minus,
34
+ }
35
+
15
36
  export interface KpiCell {
16
- /** Caption shown above the value. */
37
+ /** Caption shown above the value. Part of the cell's accessible name. */
17
38
  label: ReactNode
18
39
  /** The headline figure. Pre-format numbers for locale/currency. */
19
40
  value: ReactNode
20
41
  /** Small fraction shown next to the value, e.g. ` /14`. */
21
42
  fraction?: ReactNode
22
- /** Optional trend line under the value (e.g. `"+12% vs last week"`). */
43
+ /** Optional trend line under the value (e.g. `"+12% vs last week"`); its words stay neutral. */
23
44
  trend?: ReactNode
24
45
  /**
25
- * Tones the trend line's dot. `up` reads as bad (danger), `down` as good
26
- * (success); the trend text itself stays in the foreground.
46
+ * Draws a Lucide direction icon (TrendingUp / TrendingDown / Minus) before
47
+ * the trend line, in the trend's tone: `up` reads as bad (danger), `down`
48
+ * as good (success) unless {@link KpiCell.trendTone} says otherwise.
49
+ */
50
+ trendDirection?: TrendDirection
51
+ /**
52
+ * Explicit tone of the trend — overrides the direction's default. Shown as
53
+ * the direction icon's colour, or as a dot when there is no direction.
27
54
  */
28
- trendDirection?: "up" | "down" | "flat"
55
+ trendTone?: ToneName
29
56
  /**
30
- * Status of the metric (e.g. `"danger"` for a breached one), shown as a dot
31
- * before the label. The figure itself stays in the foreground colour.
57
+ * The state the figure stands for (e.g. `"danger"` for a breached metric),
58
+ * shown as a dot next to the label (strip) or as the card's tint and edge
59
+ * (`variant="soft"`). The figure itself stays in the foreground colour.
32
60
  */
33
- tone?: ToneVariant
61
+ tone?: ToneName
34
62
  /** When set, the cell renders as a button — turns a metric into a nav entry point. */
35
63
  onClick?: () => void
36
- /** Accessible label for the clickable cell (recommended when `onClick` is set). */
64
+ /**
65
+ * What activating the cell does ("Opens the incidents dashboard"). Read as
66
+ * the button's DESCRIPTION (`aria-describedby`), so its accessible name
67
+ * stays the visible label and value (WCAG 2.5.3, label in name).
68
+ */
69
+ actionHint?: string
70
+ /**
71
+ * @deprecated Replaces the accessible name with text a voice-control user
72
+ * cannot see (WCAG 2.5.3). Say what the click does in
73
+ * {@link KpiCell.actionHint}; the visible label and value name the cell.
74
+ */
37
75
  ariaLabel?: string
38
76
  }
39
77
 
78
+ /**
79
+ * Columns per cell count. 4+ cells fall back to 2 columns in narrow frames
80
+ * (inline chat views are often under 500 px, where five cells side by side
81
+ * leave ~40 px each); 6 steps through 3.
82
+ */
40
83
  const COL_CLASS: Record<number, string> = {
41
84
  1: "grid-cols-1",
42
85
  2: "grid-cols-2",
43
86
  3: "grid-cols-3",
44
- 4: "grid-cols-4",
45
- 5: "grid-cols-5",
46
- 6: "grid-cols-6",
87
+ 4: "grid-cols-2 sm:grid-cols-4",
88
+ 5: "grid-cols-2 sm:grid-cols-5",
89
+ 6: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-6",
90
+ }
91
+
92
+ /** The responsive column classes for `count` cells (clamped to 1–6). */
93
+ export function kpiColumns(count: number): string {
94
+ const cols = Math.min(Math.max(Math.trunc(count) || 1, 1), 6)
95
+ return COL_CLASS[cols] ?? "grid-cols-1"
47
96
  }
48
97
 
49
98
  export interface KpiGridHeader {
50
- /** Group label, rendered uppercase in the strip header. */
99
+ /** Group label of the strip header (13 px semibold, normal case). */
51
100
  label: ReactNode
52
101
  /** Optional muted badge to the right of the label. */
53
102
  badge?: ReactNode
54
103
  }
55
104
 
56
105
  export interface KpiGridProps {
57
- /** The metric cells, flowed as columns (1–6, clamped). */
106
+ /** The metric cells, flowed as columns (1–6; more wrap onto further rows). */
58
107
  cells: KpiCell[]
59
108
  /** Wrap the strip in a bordered card with an optional group `header`. Default `false`. */
60
109
  boxed?: boolean
61
- /** Group header shown above the strip when `boxed`. */
110
+ /** Group header above the strip (strip variant), usually with `boxed`. */
62
111
  header?: KpiGridHeader
112
+ /**
113
+ * `"strip"` (default): one bordered strip, the tone as a dot next to the
114
+ * label. `"soft"`: one card per cell in its tone's tint and edge (`boxed`
115
+ * and `header` don't apply).
116
+ */
117
+ variant?: "strip" | "soft"
118
+ /** Names the group of metrics (`role="group"`); omit when a heading right above names it. */
119
+ ariaLabel?: string
120
+ className?: string
63
121
  }
64
122
 
65
123
  /** A small status dot; decorative — the label next to it carries the meaning. */
66
- function ToneDot({ tone, size }: { tone: ToneVariant; size: "size-1.5" | "size-2" }) {
67
- return <span aria-hidden="true" className={`${size} shrink-0 rounded-full ${TONE_DOT[tone]}`} />
124
+ function ToneDot({ tone, className }: { tone: ToneVariant; className: string }) {
125
+ return (
126
+ <span
127
+ aria-hidden="true"
128
+ data-tone={tone}
129
+ className={cn("shrink-0 rounded-full", className, TONE_DOT[tone])}
130
+ />
131
+ )
68
132
  }
69
133
 
70
134
  /**
71
- * The trend line under a value: a tone dot plus the trend text in the
72
- * foreground — never in a tone colour. Without a tone it stays quiet text.
135
+ * The trend line: neutral words (the foreground next to a state, quiet
136
+ * without one) and the state beside them as a direction icon or a dot in the
137
+ * tone — never as coloured digits.
73
138
  */
74
- function KpiTrend({
75
- trend,
76
- direction,
77
- }: {
78
- trend: ReactNode
79
- direction?: KpiCell["trendDirection"]
80
- }) {
81
- const tone = direction ? TREND_TONE[direction] : "neutral"
82
- const textClass = tone === "neutral" ? "text-muted-foreground" : "text-foreground"
139
+ function TrendLine({ cell }: { cell: KpiCell }) {
140
+ const direction = cell.trendDirection
141
+ const tone =
142
+ cell.trendTone !== undefined
143
+ ? resolveTone(cell.trendTone)
144
+ : direction
145
+ ? TREND_TONE[direction]
146
+ : "neutral"
147
+ const marked = tone !== "neutral"
148
+ const Icon = direction ? TREND_ICON[direction] : undefined
83
149
  return (
84
- <div className={`mt-1.5 inline-flex items-center gap-1.5 text-xs ${textClass}`}>
85
- {tone !== "neutral" && <ToneDot tone={tone} size="size-1.5" />}
86
- {trend}
150
+ <div data-slot="kpi-trend" className="text-small mt-1.5 flex items-center gap-1.5">
151
+ {Icon ? (
152
+ <Icon aria-hidden="true" className={cn("size-4 shrink-0", TONE_ICON[tone])} />
153
+ ) : (
154
+ marked && <ToneDot tone={tone} className="size-1.5" />
155
+ )}
156
+ <span className={marked ? "text-foreground" : "text-muted-foreground"}>{cell.trend}</span>
87
157
  </div>
88
158
  )
89
159
  }
90
160
 
91
- /** One cell's label row, figure and optional trend — status as dots, never as coloured digits. */
92
- function KpiCellContent({ cell }: { cell: KpiCell }) {
161
+ /** Label, figure, fraction and trend — shared by the strip and soft variants. */
162
+ function KpiCellBody({ cell, variant }: { cell: KpiCell; variant: "strip" | "soft" }) {
163
+ const tone = resolveTone(cell.tone)
93
164
  return (
94
165
  <>
95
- <div className="text-muted-foreground flex items-center justify-between gap-2 text-xs font-medium">
166
+ <div
167
+ className={cn(
168
+ "text-small flex items-center justify-between gap-2 font-medium",
169
+ variant === "strip" ? "text-muted-foreground" : "text-foreground",
170
+ )}
171
+ >
96
172
  <span className="inline-flex min-w-0 items-center gap-1.5">
97
- {cell.tone && cell.tone !== "neutral" && <ToneDot tone={cell.tone} size="size-2" />}
98
- <span>{cell.label}</span>
173
+ {variant === "strip" && tone !== "neutral" && <ToneDot tone={tone} className="size-2" />}
174
+ <span className="min-w-0 break-words">{cell.label}</span>
99
175
  </span>
100
- {cell.onClick && (
101
- <span aria-hidden="true" className="text-sm leading-none">
102
- ›
103
- </span>
104
- )}
176
+ {cell.onClick && <ChevronRight aria-hidden="true" className="size-4 shrink-0" />}
105
177
  </div>
106
- <div className="text-foreground mt-1.5 text-2xl leading-none font-bold tracking-tight tabular-nums">
178
+ <div className="text-title text-foreground mt-1.5 leading-none tracking-tight break-words tabular-nums">
107
179
  {cell.value}
108
180
  {cell.fraction && (
109
- <span className="text-muted-foreground ml-0.5 text-sm font-normal">{cell.fraction}</span>
181
+ <span className="text-dense text-muted-foreground ml-0.5 font-normal">
182
+ {cell.fraction}
183
+ </span>
110
184
  )}
111
185
  </div>
112
- {cell.trend && <KpiTrend trend={cell.trend} direction={cell.trendDirection} />}
186
+ {cell.trend && <TrendLine cell={cell} />}
113
187
  </>
114
188
  )
115
189
  }
116
190
 
191
+ /** One cell: a button when clickable (named by its visible text), else a plain box. */
192
+ function KpiCellView({
193
+ cell,
194
+ variant,
195
+ className,
196
+ }: {
197
+ cell: KpiCell
198
+ variant: "strip" | "soft"
199
+ className: string
200
+ }) {
201
+ const hintId = useId()
202
+ const body = <KpiCellBody cell={cell} variant={variant} />
203
+ const tone = cell.tone === undefined ? undefined : resolveTone(cell.tone)
204
+ if (!cell.onClick) {
205
+ return (
206
+ <div data-tone={tone} className={className}>
207
+ {body}
208
+ </div>
209
+ )
210
+ }
211
+ return (
212
+ <button
213
+ type="button"
214
+ onClick={cell.onClick}
215
+ aria-label={cell.ariaLabel}
216
+ aria-describedby={cell.actionHint ? hintId : undefined}
217
+ data-tone={tone}
218
+ className={cn(
219
+ "cursor-pointer text-left transition-colors",
220
+ variant === "strip" ? cn("hover:bg-muted", FOCUS_RING_INSET) : FOCUS_RING,
221
+ className,
222
+ )}
223
+ >
224
+ {body}
225
+ {/* Referenced by aria-describedby; `hidden` keeps it out of the name and the page. */}
226
+ {cell.actionHint && (
227
+ <span id={hintId} hidden>
228
+ {cell.actionHint}
229
+ </span>
230
+ )}
231
+ </button>
232
+ )
233
+ }
234
+
235
+ /** A soft card: the tone's tint and edge around neutral text. */
236
+ function softCardClass(tone: ToneName | undefined): string {
237
+ const t = resolveTone(tone)
238
+ return cn("text-foreground rounded-lg border p-4", TONE_TINT[t], TONE_BORDER[t])
239
+ }
240
+
117
241
  /**
118
- * Bordered strip of KPI cells — the canonical way to show a row of headline
119
- * metrics in a widget (replaces the single-metric KPICard). Cells flow as
120
- * columns (1–6) and can be made clickable to drill into a detail view. Use
121
- * `boxed` + `header` for a labelled metric block; unboxed (default) renders the
122
- * lighter `border-y` strip. A cell's `tone` shows as a status dot next to its
123
- * label; figures stay in the foreground colour.
242
+ * The strip's dividers. Each cell draws its own right and bottom rule, and the
243
+ * grid reaches 1 px past its clipping frame, so the rules on the outer edges
244
+ * fall away at any column count. An empty track in a short last row (5 cells
245
+ * in 2 columns, 7 in 6) keeps the card colour: a `gap-px` grid on a
246
+ * `bg-border` container would paint it as a solid block of the divider colour.
124
247
  */
125
- export function KpiGrid({ cells, boxed = false, header }: KpiGridProps) {
126
- const cols = Math.min(Math.max(cells.length, 1), 6)
127
- const colClass = COL_CLASS[cols] ?? "grid-cols-4"
128
- const wrapperClass = boxed ? "border-border overflow-hidden rounded-lg border" : ""
129
- const stripClass = boxed ? "" : "border-border border-y"
248
+ const STRIP_FRAME = "bg-card overflow-hidden"
249
+ const STRIP_GRID = "-mr-px -mb-px grid"
250
+ const STRIP_CELL = "border-border min-w-0 border-r border-b px-5 py-4"
251
+
252
+ function KpiStripHeader({ header }: { header: KpiGridHeader }) {
130
253
  return (
131
- <div className={wrapperClass}>
132
- {header && (
133
- <div className="border-border bg-muted text-muted-foreground flex items-center gap-2 border-b px-4 py-2 text-[11px] font-semibold tracking-wide uppercase">
134
- <span>{header.label}</span>
135
- {header.badge && (
136
- <span className="border-border bg-card text-muted-foreground rounded-sm border px-1.5 py-0 text-[10px] font-medium normal-case">
137
- {header.badge}
138
- </span>
139
- )}
140
- </div>
254
+ <div
255
+ className={cn(
256
+ "border-border bg-muted text-muted-foreground flex items-center gap-2 border-b px-4 py-2",
257
+ MICRO_LABEL,
258
+ )}
259
+ >
260
+ <span>{header.label}</span>
261
+ {header.badge && (
262
+ <span className="border-border bg-card text-muted-foreground text-small rounded-sm border px-1.5 font-medium">
263
+ {header.badge}
264
+ </span>
141
265
  )}
142
- <div className={`grid ${colClass} ${stripClass}`}>
143
- {cells.map((cell, idx) => {
144
- const borderClass = idx < cells.length - 1 ? "border-border border-r" : ""
145
- const content = <KpiCellContent cell={cell} />
146
- return cell.onClick ? (
147
- <button
148
- key={idx}
149
- type="button"
150
- onClick={cell.onClick}
151
- aria-label={cell.ariaLabel}
152
- className={`hover:bg-muted cursor-pointer px-5 py-4 text-left transition-colors ${FOCUS_RING_INSET} ${borderClass}`}
153
- >
154
- {content}
155
- </button>
156
- ) : (
157
- <div key={idx} className={`px-5 py-4 ${borderClass}`}>
158
- {content}
266
+ </div>
267
+ )
268
+ }
269
+
270
+ /**
271
+ * A row of headline metrics — the canonical way to show KPIs in a widget
272
+ * (replaces the single-metric KPICard). Cells flow as columns (1–6), falling
273
+ * back to 2 columns in narrow frames. A cell's `tone` shows as a dot next to
274
+ * its label (or as a soft card's tint and edge); figures stay in the
275
+ * foreground colour. A clickable cell is a button whose accessible name is
276
+ * its visible label and value; `actionHint` describes what the click does.
277
+ */
278
+ export function KpiGrid({
279
+ cells,
280
+ boxed = false,
281
+ header,
282
+ variant = "strip",
283
+ ariaLabel,
284
+ className,
285
+ }: KpiGridProps) {
286
+ const colClass = kpiColumns(cells.length)
287
+ const group = ariaLabel ? { role: "group" as const, "aria-label": ariaLabel } : {}
288
+ if (variant === "soft") {
289
+ return (
290
+ <div {...group} data-slot="kpi-grid" className={cn("grid gap-4", colClass, className)}>
291
+ {cells.map((cell, idx) => (
292
+ <KpiCellView
293
+ key={idx}
294
+ cell={cell}
295
+ variant="soft"
296
+ className={cn("min-w-0", softCardClass(cell.tone))}
297
+ />
298
+ ))}
299
+ </div>
300
+ )
301
+ }
302
+ return (
303
+ <div
304
+ {...group}
305
+ data-slot="kpi-grid"
306
+ className={cn(boxed && "border-border overflow-hidden rounded-lg border", className)}
307
+ >
308
+ {header && <KpiStripHeader header={header} />}
309
+ <div data-slot="kpi-strip" className={cn(STRIP_FRAME, !boxed && "border-border border-y")}>
310
+ <div className={cn(STRIP_GRID, colClass)}>
311
+ {cells.map((cell, idx) => (
312
+ <KpiCellView key={idx} cell={cell} variant="strip" className={STRIP_CELL} />
313
+ ))}
314
+ </div>
315
+ </div>
316
+ </div>
317
+ )
318
+ }
319
+
320
+ export interface KpiGridSkeletonProps {
321
+ /** How many cells the loaded grid will have. */
322
+ cells: number
323
+ /** The loaded grid's variant. Default `"strip"`. */
324
+ variant?: "strip" | "soft"
325
+ /** The loaded grid's `boxed`. Default `false`. */
326
+ boxed?: boolean
327
+ className?: string
328
+ }
329
+
330
+ /**
331
+ * Loading placeholder with {@link KpiGrid}'s geometry (same wrapper, grid and
332
+ * cell padding in both variants), so the layout doesn't jump when the data
333
+ * arrives. `aria-busy`; pair it with a status line if the wait is long.
334
+ */
335
+ export function KpiGridSkeleton({
336
+ cells,
337
+ variant = "strip",
338
+ boxed = false,
339
+ className,
340
+ }: KpiGridSkeletonProps) {
341
+ const colClass = kpiColumns(cells)
342
+ const count = Math.max(Math.trunc(cells) || 0, 0)
343
+ if (variant === "soft") {
344
+ return (
345
+ <div aria-busy="true" className={cn("grid gap-4", colClass, className)}>
346
+ {Array.from({ length: count }, (_, idx) => (
347
+ <div key={idx} className="bg-muted border-border rounded-lg border p-4">
348
+ <Skeleton className="h-4 w-20" />
349
+ <Skeleton className="mt-2 h-7 w-14" />
350
+ </div>
351
+ ))}
352
+ </div>
353
+ )
354
+ }
355
+ return (
356
+ <div
357
+ aria-busy="true"
358
+ className={cn(boxed && "border-border overflow-hidden rounded-lg border", className)}
359
+ >
360
+ <div data-slot="kpi-strip" className={cn(STRIP_FRAME, !boxed && "border-border border-y")}>
361
+ <div className={cn(STRIP_GRID, colClass)}>
362
+ {Array.from({ length: count }, (_, idx) => (
363
+ <div key={idx} className={STRIP_CELL}>
364
+ <Skeleton className="h-4 w-16" />
365
+ <Skeleton className="mt-2.5 h-7 w-14" />
159
366
  </div>
160
- )
161
- })}
367
+ ))}
368
+ </div>
162
369
  </div>
163
370
  </div>
164
371
  )
@@ -0,0 +1,116 @@
1
+ import { renderToStaticMarkup } from "react-dom/server"
2
+ import { describe, expect, it } from "vitest"
3
+ import { LocaleProvider } from "../locale/locale-provider.js"
4
+ import { attribute, openingTags } from "../render-scan.js"
5
+ import { FOCUS_RING } from "../lib/focus-ring.js"
6
+ import { ListFooter, type ListFooterProps } from "./ListFooter.js"
7
+
8
+ const noop = (): void => {}
9
+
10
+ function render(props: Partial<ListFooterProps> = {}, locale = "en") {
11
+ return renderToStaticMarkup(
12
+ <LocaleProvider locale={locale}>
13
+ <ListFooter shown={8} total={42} hasMore onLoadMore={noop} noun="tasks" {...props} />
14
+ </LocaleProvider>,
15
+ )
16
+ }
17
+
18
+ /** The text of the live region (the one `role="status"` element). */
19
+ function statusText(html: string): string | undefined {
20
+ const match = /<p role="status"[^>]*>([\s\S]*?)<\/p>/.exec(html)
21
+ return match?.[1]
22
+ ?.replace(/<[^>]*>/g, " ")
23
+ .replace(/\s+/g, " ")
24
+ .trim()
25
+ }
26
+
27
+ /** The class of the footer's root (`sr-only` while nothing is visible). */
28
+ function footerClass(html: string): string | undefined {
29
+ return attribute(openingTags(html, "div")[0] ?? "", "class")
30
+ }
31
+
32
+ describe("ListFooter: an honest count in a polite live region", () => {
33
+ it("puts the count in ONE status region and Load more outside it", () => {
34
+ const html = render()
35
+ expect(html.match(/role="status"/g)).toHaveLength(1)
36
+ expect(statusText(html)).toBe("Showing 8 of 42 tasks")
37
+ const [button] = openingTags(html, "button")
38
+ expect(attribute(button ?? "", "type")).toBe("button")
39
+ expect(attribute(button ?? "", "class")).toContain(FOCUS_RING)
40
+ expect(html.indexOf("<button")).toBeGreaterThan(html.indexOf("</p>"))
41
+ expect(html).toContain("text-small")
42
+ expect(html).not.toContain("text-xs")
43
+ })
44
+
45
+ it("says Updating… next to the count while a same-filter refetch runs", () => {
46
+ expect(statusText(render({ refreshing: true }))).toBe("Showing 8 of 42 tasks Updating…")
47
+ expect(statusText(render({ refreshing: true }, "de"))).toBe(
48
+ "Zeigt 8 von 42 tasks Wird aktualisiert…",
49
+ )
50
+ })
51
+
52
+ it("hides the count and Load more while the rows are the previous result", () => {
53
+ const inFlight = render({ stale: true, refreshing: true })
54
+ expect(statusText(inFlight)).toBe("Updating…")
55
+ expect(inFlight).not.toContain("<button")
56
+ // A failed search: the caller's alert reports it, the region stays, empty.
57
+ const failed = render({ stale: true })
58
+ expect(footerClass(failed)).toBe("sr-only")
59
+ expect(statusText(failed)).toBe("")
60
+ expect(failed).not.toContain("<button")
61
+ })
62
+
63
+ it("shows nothing at zero rows, but keeps the live region and says so to screen readers", () => {
64
+ const empty = render({ total: 0, hasMore: false })
65
+ expect(footerClass(empty)).toBe("sr-only")
66
+ expect(statusText(empty)).toBe("No entries")
67
+ expect(statusText(render({ total: 0, hasMore: false }, "de"))).toBe("Keine Einträge")
68
+ expect(empty).not.toContain("<button")
69
+ expect(statusText(render({ total: 0, refreshing: true }))).toBe("Updating…")
70
+ expect(footerClass(render({ total: 0, refreshing: true }))).toContain("flex")
71
+ })
72
+
73
+ it("keeps the SAME live region from a count to zero rows, so a search that finds nothing is announced", () => {
74
+ // React keeps an element whose type and position stay put; a region that
75
+ // unmounts and comes back with its news already in it is not announced.
76
+ const path = (html: string) =>
77
+ [...html.matchAll(/<(div|p)\b[^>]*?(data-slot="[^"]*")/g)].map((m) => `${m[1]}[${m[2]}]`)
78
+ const states = [
79
+ render(),
80
+ render({ refreshing: true }),
81
+ render({ total: 0, hasMore: false }),
82
+ render({ stale: true }),
83
+ ]
84
+ for (const html of states) {
85
+ expect(path(html)).toEqual([
86
+ 'div[data-slot="list-footer"]',
87
+ 'p[data-slot="list-footer-status"]',
88
+ ])
89
+ expect(html.match(/role="status"/g)).toHaveLength(1)
90
+ }
91
+ expect(states.map(statusText)).toEqual([
92
+ "Showing 8 of 42 tasks",
93
+ "Showing 8 of 42 tasks Updating…",
94
+ "No entries",
95
+ "",
96
+ ])
97
+ })
98
+
99
+ it("leaves the announcing to a caller that owns a live region (announce={false})", () => {
100
+ const html = render({ announce: false, refreshing: true })
101
+ expect(html).not.toContain('role="status"')
102
+ expect(html).toMatch(/<p data-slot="list-footer-status"[^>]*>.*Showing 8 of 42 tasks/)
103
+ expect(html).toContain("Updating…")
104
+ // Nothing visible and no region to keep: nothing at all.
105
+ expect(render({ announce: false, total: 0 })).toBe("")
106
+ expect(render({ announce: false, stale: true })).toBe("")
107
+ })
108
+
109
+ it("disables Load more while a page is in flight, and takes label overrides", () => {
110
+ const html = render({ loadingMore: true, labels: { loadingMore: "Fetching…" } })
111
+ const [button] = openingTags(html, "button")
112
+ expect(attribute(button ?? "", "disabled")).toBe("")
113
+ expect(html).toContain("Fetching…</button>")
114
+ expect(render({ hasMore: false })).not.toContain("<button")
115
+ })
116
+ })