basalt-ui 1.25.0 → 1.26.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 (462) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +18 -0
  3. package/MIGRATING.md +546 -20
  4. package/README.md +45 -32
  5. package/agent/rules/basalt-batteries.md +205 -0
  6. package/agent/rules/basalt-charts.md +125 -555
  7. package/agent/rules/basalt-controls.md +155 -0
  8. package/agent/rules/basalt-mantine.md +146 -439
  9. package/agent/rules/basalt-state.md +113 -96
  10. package/agent/rules/basalt-tokens.md +126 -288
  11. package/agent/skills/basalt-app/SKILL.md +70 -195
  12. package/agent/skills/basalt-charts/SKILL.md +54 -234
  13. package/agent/skills/basalt-design/SKILL.md +56 -248
  14. package/agent/templates/CLAUDE-block.md.tpl +31 -40
  15. package/agent/templates/DESIGN.md.tpl +22 -51
  16. package/configs/oxlint-plugin.js +1275 -31
  17. package/configs/oxlint-plugin.test.ts +947 -11
  18. package/configs/oxlint.json +11 -3
  19. package/dist/charts/cursor/resolve.d.ts +18 -0
  20. package/dist/charts/cursor/resolve.d.ts.map +1 -1
  21. package/dist/charts/cursor/resolve.js +15 -6
  22. package/dist/charts/cursor/scope.d.ts +6 -3
  23. package/dist/charts/cursor/scope.d.ts.map +1 -1
  24. package/dist/charts/cursor/store.d.ts +5 -1
  25. package/dist/charts/cursor/store.d.ts.map +1 -1
  26. package/dist/charts/cursor/store.js +4 -4
  27. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
  28. package/dist/charts/hooks/useChartCursor.js +9 -8
  29. package/dist/charts/index.d.ts +2 -2
  30. package/dist/charts/index.d.ts.map +1 -1
  31. package/dist/charts/primitives/ChartCard.d.ts +34 -9
  32. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  33. package/dist/charts/primitives/ChartCard.js +29 -114
  34. package/dist/charts/primitives/ChartCard.test.d.ts +2 -0
  35. package/dist/charts/primitives/ChartCard.test.d.ts.map +1 -0
  36. package/dist/charts/sparklines/BarSparkline.d.ts +26 -2
  37. package/dist/charts/sparklines/BarSparkline.d.ts.map +1 -1
  38. package/dist/charts/sparklines/BarSparkline.js +18 -4
  39. package/dist/cli/check-coverage.test.d.ts +2 -0
  40. package/dist/cli/check-coverage.test.d.ts.map +1 -0
  41. package/dist/cli/index.d.ts +67 -4
  42. package/dist/cli/index.d.ts.map +1 -1
  43. package/dist/cli/index.js +225 -7
  44. package/dist/connectivity/connectivity-indicator.d.ts +4 -1
  45. package/dist/connectivity/connectivity-indicator.d.ts.map +1 -1
  46. package/dist/connectivity/connectivity-indicator.js +1 -1
  47. package/dist/content/index.d.ts +5 -3
  48. package/dist/content/index.d.ts.map +1 -1
  49. package/dist/content/index.js +4 -2
  50. package/dist/content/prose.module.css +7 -28
  51. package/dist/controls/actions.d.ts +169 -0
  52. package/dist/controls/actions.d.ts.map +1 -0
  53. package/dist/controls/actions.js +287 -0
  54. package/dist/controls/actions.module.css +62 -0
  55. package/dist/controls/actions.module.css.d.ts +13 -0
  56. package/dist/controls/actions.test.d.ts +2 -0
  57. package/dist/controls/actions.test.d.ts.map +1 -0
  58. package/dist/controls/actions.type-guard.test.d.ts +2 -0
  59. package/dist/controls/actions.type-guard.test.d.ts.map +1 -0
  60. package/dist/controls/compare-filter.d.ts +41 -0
  61. package/dist/controls/compare-filter.d.ts.map +1 -0
  62. package/dist/controls/compare-filter.js +23 -0
  63. package/dist/controls/control-group.d.ts +19 -0
  64. package/dist/controls/control-group.d.ts.map +1 -0
  65. package/dist/controls/control-group.js +20 -0
  66. package/dist/controls/control-group.test.d.ts +2 -0
  67. package/dist/controls/control-group.test.d.ts.map +1 -0
  68. package/dist/controls/controls.module.css +542 -0
  69. package/dist/controls/controls.module.css.d.ts +35 -0
  70. package/dist/controls/controls.router.test.d.ts +2 -0
  71. package/dist/controls/controls.router.test.d.ts.map +1 -0
  72. package/dist/controls/enum-filter.d.ts +30 -0
  73. package/dist/controls/enum-filter.d.ts.map +1 -0
  74. package/dist/controls/enum-filter.js +91 -0
  75. package/dist/controls/filter-context.d.ts +55 -0
  76. package/dist/controls/filter-context.d.ts.map +1 -0
  77. package/dist/controls/filter-context.js +69 -0
  78. package/dist/controls/filter-pill.d.ts +28 -0
  79. package/dist/controls/filter-pill.d.ts.map +1 -0
  80. package/dist/controls/filter-pill.js +70 -0
  81. package/dist/controls/filter-set.d.ts +13 -0
  82. package/dist/controls/filter-set.d.ts.map +1 -0
  83. package/dist/controls/filter-set.js +125 -0
  84. package/dist/controls/filter-set.test.d.ts +2 -0
  85. package/dist/controls/filter-set.test.d.ts.map +1 -0
  86. package/dist/controls/filter-sheet.d.ts +117 -0
  87. package/dist/controls/filter-sheet.d.ts.map +1 -0
  88. package/dist/controls/filter-sheet.js +146 -0
  89. package/dist/controls/glyphs.d.ts +27 -0
  90. package/dist/controls/glyphs.d.ts.map +1 -0
  91. package/dist/controls/glyphs.js +49 -0
  92. package/dist/controls/index.d.ts +19 -0
  93. package/dist/controls/index.d.ts.map +1 -0
  94. package/dist/controls/index.js +44 -0
  95. package/dist/controls/multi-select-filter.d.ts +23 -0
  96. package/dist/controls/multi-select-filter.d.ts.map +1 -0
  97. package/dist/controls/multi-select-filter.js +89 -0
  98. package/dist/controls/peer-boundary.test.d.ts +2 -0
  99. package/dist/controls/peer-boundary.test.d.ts.map +1 -0
  100. package/dist/controls/props-contract.test.d.ts +2 -0
  101. package/dist/controls/props-contract.test.d.ts.map +1 -0
  102. package/dist/controls/range-filter.d.ts +29 -0
  103. package/dist/controls/range-filter.d.ts.map +1 -0
  104. package/dist/controls/range-filter.js +120 -0
  105. package/dist/controls/search-filter.d.ts +10 -0
  106. package/dist/controls/search-filter.d.ts.map +1 -0
  107. package/dist/controls/search-filter.js +81 -0
  108. package/dist/controls/select-filter.d.ts +74 -0
  109. package/dist/controls/select-filter.d.ts.map +1 -0
  110. package/dist/controls/select-filter.js +10 -0
  111. package/dist/controls/sync-button.d.ts +38 -0
  112. package/dist/controls/sync-button.d.ts.map +1 -0
  113. package/dist/controls/sync-button.js +119 -0
  114. package/dist/controls/sync-button.module.css +63 -0
  115. package/dist/controls/sync-button.module.css.d.ts +11 -0
  116. package/dist/controls/sync-button.test.d.ts +2 -0
  117. package/dist/controls/sync-button.test.d.ts.map +1 -0
  118. package/dist/controls/toggle-filter.d.ts +9 -0
  119. package/dist/controls/toggle-filter.d.ts.map +1 -0
  120. package/dist/controls/toggle-filter.js +42 -0
  121. package/dist/controls/view-tabs.d.ts +23 -0
  122. package/dist/controls/view-tabs.d.ts.map +1 -0
  123. package/dist/controls/view-tabs.js +59 -0
  124. package/dist/controls-dates/date-range-picker.d.ts +4 -0
  125. package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
  126. package/dist/controls-dates/date-range-picker.js +18 -0
  127. package/dist/controls-dates/index.d.ts +9 -0
  128. package/dist/controls-dates/index.d.ts.map +1 -0
  129. package/dist/controls-dates/index.js +4 -0
  130. package/dist/dashboard/delta-badge.d.ts +6 -12
  131. package/dist/dashboard/delta-badge.d.ts.map +1 -1
  132. package/dist/dashboard/delta-badge.js +1 -50
  133. package/dist/dashboard/settings-section.d.ts +6 -4
  134. package/dist/dashboard/settings-section.d.ts.map +1 -1
  135. package/dist/dashboard/settings-section.js +23 -16
  136. package/dist/dashboard/settings-section.module.css +10 -0
  137. package/dist/dashboard/settings-section.module.css.d.ts +1 -0
  138. package/dist/dashboard/settings-section.test.d.ts +2 -0
  139. package/dist/dashboard/settings-section.test.d.ts.map +1 -0
  140. package/dist/dashboard/stat-card.d.ts +44 -9
  141. package/dist/dashboard/stat-card.d.ts.map +1 -1
  142. package/dist/dashboard/stat-card.js +80 -48
  143. package/dist/dashboard/stat-card.module.css +82 -0
  144. package/dist/dashboard/stat-card.module.css.d.ts +11 -0
  145. package/dist/data/data-table.d.ts +37 -15
  146. package/dist/data/data-table.d.ts.map +1 -1
  147. package/dist/data/data-table.js +88 -58
  148. package/dist/guard/grace.test.d.ts +20 -0
  149. package/dist/guard/grace.test.d.ts.map +1 -0
  150. package/dist/guard/index.d.ts +66 -3
  151. package/dist/guard/index.d.ts.map +1 -1
  152. package/dist/guard/index.js +82 -7
  153. package/dist/guard/types.d.ts +3 -3
  154. package/dist/guard/types.d.ts.map +1 -1
  155. package/dist/index.d.ts +12 -2
  156. package/dist/index.d.ts.map +1 -1
  157. package/dist/index.js +13 -10
  158. package/dist/nav/types.d.ts +78 -2
  159. package/dist/nav/types.d.ts.map +1 -1
  160. package/dist/notifications/bell.d.ts +4 -1
  161. package/dist/notifications/bell.d.ts.map +1 -1
  162. package/dist/notifications/index.d.ts +1 -1
  163. package/dist/router-tanstack/field.d.ts +14 -0
  164. package/dist/router-tanstack/field.d.ts.map +1 -0
  165. package/dist/router-tanstack/field.js +4 -0
  166. package/dist/router-tanstack/index.d.ts +6 -2
  167. package/dist/router-tanstack/index.d.ts.map +1 -1
  168. package/dist/router-tanstack/index.js +4 -0
  169. package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
  170. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  171. package/dist/router-tanstack/multi-search-param-store.js +19 -27
  172. package/dist/router-tanstack/search-param-store.d.ts +3 -87
  173. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  174. package/dist/router-tanstack/search-param-store.js +16 -45
  175. package/dist/router-tanstack/search-store.d.ts +81 -0
  176. package/dist/router-tanstack/search-store.d.ts.map +1 -0
  177. package/dist/router-tanstack/search-store.js +187 -0
  178. package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
  179. package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
  180. package/dist/router-tanstack/search-store.test.d.ts +2 -0
  181. package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
  182. package/dist/router-tanstack/use-nav.d.ts +0 -2
  183. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  184. package/dist/router-tanstack/use-nav.js +34 -12
  185. package/dist/router-tanstack/use-nav.test.d.ts +2 -0
  186. package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
  187. package/dist/section/index.d.ts +2 -0
  188. package/dist/section/index.d.ts.map +1 -0
  189. package/dist/section/index.js +4 -0
  190. package/dist/section/section.d.ts +24 -0
  191. package/dist/section/section.d.ts.map +1 -0
  192. package/dist/section/section.js +126 -0
  193. package/dist/section/section.module.css +104 -0
  194. package/dist/section/section.module.css.d.ts +11 -0
  195. package/dist/section/section.test.d.ts +2 -0
  196. package/dist/section/section.test.d.ts.map +1 -0
  197. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  198. package/dist/shell/app-breadcrumbs.js +18 -15
  199. package/dist/shell/app-header.module.css +77 -37
  200. package/dist/shell/app-header.module.css.d.ts +2 -1
  201. package/dist/shell/app-mobile-nav.d.ts +9 -2
  202. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  203. package/dist/shell/app-mobile-nav.js +98 -11
  204. package/dist/shell/app-mobile-nav.module.css +8 -0
  205. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  206. package/dist/shell/app-sidebar.d.ts +55 -28
  207. package/dist/shell/app-sidebar.d.ts.map +1 -1
  208. package/dist/shell/app-sidebar.js +116 -67
  209. package/dist/shell/app-sidebar.module.css +303 -20
  210. package/dist/shell/app-sidebar.module.css.d.ts +19 -1
  211. package/dist/shell/index.d.ts +55 -21
  212. package/dist/shell/index.d.ts.map +1 -1
  213. package/dist/shell/index.js +40 -18
  214. package/dist/shell/mobile-nav-model.d.ts +13 -2
  215. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  216. package/dist/shell/mobile-nav-model.js +15 -1
  217. package/dist/shell/nav-count-badge.d.ts +1 -1
  218. package/dist/shell/page-bar.d.ts +51 -0
  219. package/dist/shell/page-bar.d.ts.map +1 -0
  220. package/dist/shell/page-bar.js +154 -0
  221. package/dist/shell/page-bar.module.css +178 -0
  222. package/dist/shell/page-bar.module.css.d.ts +17 -0
  223. package/dist/shell/page-bar.test.d.ts +2 -0
  224. package/dist/shell/page-bar.test.d.ts.map +1 -0
  225. package/dist/shell/sidebar-block-model.d.ts +48 -0
  226. package/dist/shell/sidebar-block-model.d.ts.map +1 -0
  227. package/dist/shell/sidebar-block-model.js +39 -0
  228. package/dist/shell/sidebar-blocks.d.ts +32 -0
  229. package/dist/shell/sidebar-blocks.d.ts.map +1 -0
  230. package/dist/shell/sidebar-blocks.js +169 -0
  231. package/dist/shell/sidebar-search.d.ts +13 -1
  232. package/dist/shell/sidebar-search.d.ts.map +1 -1
  233. package/dist/shell/sidebar-search.js +34 -1
  234. package/dist/shell/sidebar-search.module.css +26 -0
  235. package/dist/shell/sidebar-search.module.css.d.ts +2 -0
  236. package/dist/state/fields.d.ts +392 -0
  237. package/dist/state/fields.d.ts.map +1 -0
  238. package/dist/state/fields.js +369 -0
  239. package/dist/state/fields.test.d.ts +2 -0
  240. package/dist/state/fields.test.d.ts.map +1 -0
  241. package/dist/state/persisted.d.ts +48 -0
  242. package/dist/state/persisted.d.ts.map +1 -0
  243. package/dist/state/persisted.js +105 -0
  244. package/dist/state.d.ts +11 -56
  245. package/dist/state.d.ts.map +1 -1
  246. package/dist/state.js +14 -120
  247. package/dist/styles.css +13 -0
  248. package/dist/surfaces.d.ts +106 -24
  249. package/dist/surfaces.d.ts.map +1 -1
  250. package/dist/surfaces.js +155 -19
  251. package/dist/theme/controls.module.css +16 -1
  252. package/dist/theme/ctl-theme.d.ts +32 -0
  253. package/dist/theme/ctl-theme.d.ts.map +1 -0
  254. package/dist/theme/ctl-theme.js +54 -0
  255. package/dist/theme/ctl-theme.test.d.ts +2 -0
  256. package/dist/theme/ctl-theme.test.d.ts.map +1 -0
  257. package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
  258. package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
  259. package/dist/theme/divider-law.test.d.ts +2 -0
  260. package/dist/theme/divider-law.test.d.ts.map +1 -0
  261. package/dist/theme/icon-slot.d.ts +44 -0
  262. package/dist/theme/icon-slot.d.ts.map +1 -0
  263. package/dist/theme/icon-slot.js +21 -0
  264. package/dist/theme/icon-slot.module.css +47 -0
  265. package/dist/theme/icon-slot.module.css.d.ts +8 -0
  266. package/dist/theme/icon-slot.test.d.ts +2 -0
  267. package/dist/theme/icon-slot.test.d.ts.map +1 -0
  268. package/dist/theme/index.d.ts +1 -0
  269. package/dist/theme/index.d.ts.map +1 -1
  270. package/dist/theme/index.js +82 -5
  271. package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
  272. package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
  273. package/dist/theme/segmented-control.module.css +20 -0
  274. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  275. package/dist/tokens/index.d.ts +12 -0
  276. package/dist/tokens/index.d.ts.map +1 -1
  277. package/dist/tokens/index.js +38 -8
  278. package/dist/tokens/palette.d.ts +97 -61
  279. package/dist/tokens/palette.d.ts.map +1 -1
  280. package/dist/tokens/palette.js +76 -38
  281. package/dist/tokens.css +10 -2
  282. package/dist/widget-header/delta-badge.d.ts +13 -0
  283. package/dist/widget-header/delta-badge.d.ts.map +1 -0
  284. package/dist/widget-header/delta-badge.js +29 -0
  285. package/dist/widget-header/delta-badge.test.d.ts +2 -0
  286. package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
  287. package/dist/widget-header/index.d.ts +3 -0
  288. package/dist/widget-header/index.d.ts.map +1 -0
  289. package/dist/widget-header/index.js +6 -0
  290. package/dist/widget-header/no-mantine.test.d.ts +2 -0
  291. package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
  292. package/dist/widget-header/widget-header.d.ts +37 -0
  293. package/dist/widget-header/widget-header.d.ts.map +1 -0
  294. package/dist/widget-header/widget-header.js +77 -0
  295. package/dist/widget-header/widget-header.module.css +335 -0
  296. package/dist/widget-header/widget-header.module.css.d.ts +24 -0
  297. package/dist/widget-header/widget-header.test.d.ts +2 -0
  298. package/dist/widget-header/widget-header.test.d.ts.map +1 -0
  299. package/llms.txt +30 -17
  300. package/package.json +14 -1
  301. package/src/charts/cursor/resolve.test.ts +29 -1
  302. package/src/charts/cursor/resolve.ts +38 -6
  303. package/src/charts/cursor/scope.tsx +6 -3
  304. package/src/charts/cursor/store.test.ts +19 -1
  305. package/src/charts/cursor/store.ts +10 -5
  306. package/src/charts/hooks/useChartCursor.ts +16 -8
  307. package/src/charts/index.ts +2 -2
  308. package/src/charts/primitives/CartesianChart.test.tsx +124 -0
  309. package/src/charts/primitives/ChartCard.test.tsx +66 -0
  310. package/src/charts/primitives/ChartCard.tsx +86 -146
  311. package/src/charts/sparklines/BarSparkline.tsx +43 -5
  312. package/src/cli/check-coverage.test.ts +297 -0
  313. package/src/cli/check-theme.test.ts +38 -14
  314. package/src/cli/index.ts +392 -16
  315. package/src/cli/init-sync.test.ts +97 -0
  316. package/src/connectivity/connectivity-indicator.tsx +16 -9
  317. package/src/content/index.ts +16 -3
  318. package/src/content/prose.module.css +7 -28
  319. package/src/content/prose.module.css.test.ts +8 -24
  320. package/src/controls/actions.module.css +62 -0
  321. package/src/controls/actions.module.css.d.ts +13 -0
  322. package/src/controls/actions.test.tsx +252 -0
  323. package/src/controls/actions.tsx +655 -0
  324. package/src/controls/actions.type-guard.test.ts +63 -0
  325. package/src/controls/compare-filter.tsx +64 -0
  326. package/src/controls/control-group.test.tsx +229 -0
  327. package/src/controls/control-group.tsx +63 -0
  328. package/src/controls/controls.module.css +542 -0
  329. package/src/controls/controls.module.css.d.ts +35 -0
  330. package/src/controls/controls.router.test.tsx +637 -0
  331. package/src/controls/enum-filter.tsx +159 -0
  332. package/src/controls/filter-context.tsx +153 -0
  333. package/src/controls/filter-pill.tsx +120 -0
  334. package/src/controls/filter-set.test.tsx +472 -0
  335. package/src/controls/filter-set.tsx +225 -0
  336. package/src/controls/filter-sheet.tsx +285 -0
  337. package/src/controls/glyphs.tsx +81 -0
  338. package/src/controls/index.ts +48 -0
  339. package/src/controls/multi-select-filter.tsx +140 -0
  340. package/src/controls/peer-boundary.test.ts +85 -0
  341. package/src/controls/props-contract.test.tsx +106 -0
  342. package/src/controls/range-filter.tsx +184 -0
  343. package/src/controls/search-filter.tsx +123 -0
  344. package/src/controls/select-filter.tsx +89 -0
  345. package/src/controls/sync-button.module.css +63 -0
  346. package/src/controls/sync-button.module.css.d.ts +11 -0
  347. package/src/controls/sync-button.test.tsx +183 -0
  348. package/src/controls/sync-button.tsx +197 -0
  349. package/src/controls/toggle-filter.tsx +60 -0
  350. package/src/controls/view-tabs.tsx +110 -0
  351. package/src/controls-dates/date-range-picker.tsx +42 -0
  352. package/src/controls-dates/index.ts +8 -0
  353. package/src/dashboard/delta-badge.tsx +4 -74
  354. package/src/dashboard/settings-section.module.css +10 -0
  355. package/src/dashboard/settings-section.module.css.d.ts +1 -0
  356. package/src/dashboard/settings-section.test.tsx +56 -0
  357. package/src/dashboard/settings-section.tsx +33 -30
  358. package/src/dashboard/stat-card.module.css +82 -0
  359. package/src/dashboard/stat-card.module.css.d.ts +11 -0
  360. package/src/dashboard/stat-card.test.tsx +67 -1
  361. package/src/dashboard/stat-card.tsx +209 -68
  362. package/src/data/data-table.test.tsx +96 -11
  363. package/src/data/data-table.tsx +220 -84
  364. package/src/guard/check-source.test.ts +126 -13
  365. package/src/guard/grace.test.ts +149 -0
  366. package/src/guard/index.ts +172 -29
  367. package/src/guard/types.ts +4 -2
  368. package/src/index.ts +46 -5
  369. package/src/nav/types.ts +84 -2
  370. package/src/notifications/bell.tsx +6 -3
  371. package/src/notifications/index.ts +1 -1
  372. package/src/router-tanstack/field.ts +31 -0
  373. package/src/router-tanstack/index.ts +24 -2
  374. package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
  375. package/src/router-tanstack/multi-search-param-store.ts +46 -77
  376. package/src/router-tanstack/search-param-store.ts +42 -159
  377. package/src/router-tanstack/search-store.router.test.tsx +661 -0
  378. package/src/router-tanstack/search-store.test.ts +418 -0
  379. package/src/router-tanstack/search-store.ts +380 -0
  380. package/src/router-tanstack/use-nav.test.tsx +187 -0
  381. package/src/router-tanstack/use-nav.ts +72 -14
  382. package/src/section/index.ts +1 -0
  383. package/src/section/section.module.css +104 -0
  384. package/src/section/section.module.css.d.ts +11 -0
  385. package/src/section/section.test.tsx +204 -0
  386. package/src/section/section.tsx +220 -0
  387. package/src/shell/app-breadcrumbs.tsx +44 -31
  388. package/src/shell/app-header.module.css +77 -37
  389. package/src/shell/app-header.module.css.d.ts +2 -1
  390. package/src/shell/app-mobile-nav.module.css +8 -0
  391. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  392. package/src/shell/app-mobile-nav.tsx +147 -8
  393. package/src/shell/app-sidebar-account.tsx +2 -2
  394. package/src/shell/app-sidebar.module.css +303 -20
  395. package/src/shell/app-sidebar.module.css.d.ts +19 -1
  396. package/src/shell/app-sidebar.test.tsx +456 -41
  397. package/src/shell/app-sidebar.tsx +276 -140
  398. package/src/shell/index.test.tsx +291 -5
  399. package/src/shell/index.tsx +137 -38
  400. package/src/shell/mobile-nav-model.test.ts +228 -7
  401. package/src/shell/mobile-nav-model.ts +53 -3
  402. package/src/shell/nav-count-badge.tsx +1 -1
  403. package/src/shell/page-bar.module.css +178 -0
  404. package/src/shell/page-bar.module.css.d.ts +17 -0
  405. package/src/shell/page-bar.test.tsx +316 -0
  406. package/src/shell/page-bar.tsx +315 -0
  407. package/src/shell/sidebar-block-model.ts +83 -0
  408. package/src/shell/sidebar-blocks.tsx +301 -0
  409. package/src/shell/sidebar-search.module.css +26 -0
  410. package/src/shell/sidebar-search.module.css.d.ts +2 -0
  411. package/src/shell/sidebar-search.tsx +71 -2
  412. package/src/state/fields.test.tsx +258 -0
  413. package/src/state/fields.ts +892 -0
  414. package/src/state/persisted.ts +211 -0
  415. package/src/state.ts +46 -240
  416. package/src/styles.css +13 -0
  417. package/src/surfaces.ts +203 -36
  418. package/src/theme/controls.module.css +16 -1
  419. package/src/theme/ctl-theme.test.tsx +275 -0
  420. package/src/theme/ctl-theme.tsx +138 -0
  421. package/src/theme/ctl-tier-coverage.test.ts +170 -0
  422. package/src/theme/density-relations.test.ts +106 -51
  423. package/src/theme/divider-law.test.ts +259 -0
  424. package/src/theme/icon-slot.module.css +47 -0
  425. package/src/theme/icon-slot.module.css.d.ts +8 -0
  426. package/src/theme/icon-slot.test.tsx +275 -0
  427. package/src/theme/icon-slot.tsx +59 -0
  428. package/src/theme/index.ts +165 -3
  429. package/src/theme/layout-rhythm-css.test.ts +72 -0
  430. package/src/theme/radius.test.ts +11 -1
  431. package/src/theme/segmented-control.module.css +20 -0
  432. package/src/theme/shadow-surfaces.test.ts +20 -7
  433. package/src/theme/spacing.test.ts +75 -27
  434. package/src/theme/use-basalt-spacing.ts +2 -2
  435. package/src/tokens/density.test.ts +7 -25
  436. package/src/tokens/index.ts +48 -8
  437. package/src/tokens/palette.ts +107 -73
  438. package/src/widget-header/delta-badge.test.tsx +60 -0
  439. package/src/widget-header/delta-badge.tsx +57 -0
  440. package/src/widget-header/index.ts +2 -0
  441. package/src/widget-header/no-mantine.test.ts +33 -0
  442. package/src/widget-header/widget-header.module.css +335 -0
  443. package/src/widget-header/widget-header.module.css.d.ts +24 -0
  444. package/src/widget-header/widget-header.test.tsx +187 -0
  445. package/src/widget-header/widget-header.tsx +168 -0
  446. package/agent/rules/basalt-agent.md +0 -452
  447. package/agent/rules/basalt-app.md +0 -246
  448. package/agent/rules/basalt-commands.md +0 -320
  449. package/agent/rules/basalt-content.md +0 -409
  450. package/agent/rules/basalt-data.md +0 -329
  451. package/agent/rules/basalt-forms.md +0 -186
  452. package/agent/rules/basalt-notifications.md +0 -264
  453. package/agent/rules/basalt-query.md +0 -187
  454. package/agent/rules/basalt-router.md +0 -336
  455. package/dist/content/article-filter-bar.d.ts +0 -40
  456. package/dist/content/article-filter-bar.d.ts.map +0 -1
  457. package/dist/content/article-filter-bar.js +0 -56
  458. package/dist/shell/page-header.d.ts +0 -13
  459. package/dist/shell/page-header.d.ts.map +0 -1
  460. package/dist/shell/page-header.js +0 -23
  461. package/src/content/article-filter-bar.tsx +0 -88
  462. package/src/shell/page-header.tsx +0 -35
@@ -0,0 +1,472 @@
1
+ /**
2
+ * `FilterSet`'s four owned behaviours (`docs/CONTROLS-SPEC.md` §2.1/§3), each asserted against real
3
+ * children bound to a real `createSearchStore`: the DERIVED active count, `Reset all` reaching every
4
+ * registered filter, the mobile `inline` budget, and the measured desktop fold.
5
+ *
6
+ * The fold is the one behaviour that needs the DOM stubbed rather than driven — happy-dom lays
7
+ * nothing out, so `offsetWidth`/`clientWidth` are 0 for every element and the no-op
8
+ * `ResizeObserver` from `tests/setup/dom.ts` never fires. Both are replaced here (restored in
9
+ * `afterEach`), which is what lets the fold arithmetic be tested at all instead of only in a
10
+ * browser.
11
+ */
12
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
13
+ import { readFileSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+ import type { ReactNode } from 'react'
16
+ import { MantineProvider } from '@mantine/core'
17
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
18
+ import {
19
+ Outlet,
20
+ RouterProvider,
21
+ createMemoryHistory,
22
+ createRootRoute,
23
+ createRoute,
24
+ createRouter,
25
+ } from '@tanstack/react-router'
26
+ import { field } from '../router-tanstack/field'
27
+ import { createSearchStore } from '../router-tanstack/search-store'
28
+ import { FilterSet } from './filter-set'
29
+ import { SelectFilter } from './select-filter'
30
+ import { ToggleFilter } from './toggle-filter'
31
+
32
+ type Router = { state: { status: string; location: { search: unknown } } }
33
+
34
+ const store = createSearchStore({
35
+ key: 'fs',
36
+ fields: {
37
+ currency: field.enum(['USD', 'EUR'], 'USD'),
38
+ region: field.enum(['all', 'eu', 'us'], 'all'),
39
+ tier: field.enum(['any', 'pro'], 'any'),
40
+ errorsOnly: field.boolean(false),
41
+ },
42
+ })
43
+
44
+ async function mountSet(entry: string, inline?: number): Promise<Router> {
45
+ const rootRoute = createRootRoute({ component: () => <Outlet /> })
46
+ const pageRoute = createRoute({
47
+ getParentRoute: () => rootRoute,
48
+ path: '/dashboard',
49
+ validateSearch: store.validateSearch,
50
+ component: () => (
51
+ <FilterSet {...(inline !== undefined && { inline })}>
52
+ <SelectFilter field={store.field.currency} label="Currency" />
53
+ <SelectFilter field={store.field.region} label="Region" />
54
+ <SelectFilter field={store.field.tier} label="Tier" />
55
+ <ToggleFilter field={store.field.errorsOnly} label="Errors only" />
56
+ </FilterSet>
57
+ ),
58
+ })
59
+ const router = createRouter({
60
+ routeTree: rootRoute.addChildren([pageRoute]),
61
+ history: createMemoryHistory({ initialEntries: [entry] }),
62
+ })
63
+
64
+ render(
65
+ <MantineProvider>
66
+ <RouterProvider router={router} />
67
+ </MantineProvider>,
68
+ )
69
+ await waitFor(() => {
70
+ expect(router.state.status).toBe('idle')
71
+ })
72
+ return router as unknown as Router
73
+ }
74
+
75
+ function slots(): HTMLElement[] {
76
+ return [...document.querySelectorAll<HTMLElement>('[data-filter-slot]')]
77
+ }
78
+
79
+ function row(): HTMLElement {
80
+ const node = document.querySelector<HTMLElement>('[data-filter-row]')
81
+ if (node === null) throw new Error('no filter row rendered')
82
+ return node
83
+ }
84
+
85
+ /** The stubbed layout: every element's width is whatever the test wrote to `data-test-width`. */
86
+ function widthFromData(this: HTMLElement): number {
87
+ return Number(this.dataset['testWidth'] ?? '0')
88
+ }
89
+
90
+ beforeEach(() => {
91
+ localStorage.clear()
92
+ })
93
+
94
+ describe('the active count is derived from the children, never passed', () => {
95
+ test('no filter is off its default → the sheet pill states no count', async () => {
96
+ await mountSet('/dashboard')
97
+ expect(screen.getByRole('button', { name: 'Filters' })).toBeDefined()
98
+ })
99
+
100
+ test('two non-default fields on a deep link → Filters (2), with nothing told to FilterSet', async () => {
101
+ await mountSet('/dashboard?currency=EUR&errorsOnly=true')
102
+ await waitFor(() => {
103
+ expect(screen.getByRole('button', { name: 'Filters (2)' })).toBeDefined()
104
+ })
105
+ })
106
+ })
107
+
108
+ describe('Reset all reaches every registered filter, including the folded and the inline ones', () => {
109
+ test('one press writes every field back to its fallback', async () => {
110
+ const router = await mountSet('/dashboard?currency=EUR&region=eu&errorsOnly=true')
111
+ await waitFor(() => {
112
+ expect(screen.getByRole('button', { name: 'Filters (3)' })).toBeDefined()
113
+ })
114
+
115
+ await act(async () => {
116
+ fireEvent.click(screen.getByRole('button', { name: 'Filters (3)' }))
117
+ })
118
+ await act(async () => {
119
+ fireEvent.click(screen.getByRole('button', { name: 'Reset all', hidden: true }))
120
+ })
121
+
122
+ await waitFor(() => {
123
+ const search = router.state.location.search as Record<string, unknown>
124
+ expect(search['currency']).toBe('USD')
125
+ expect(search['region']).toBe('all')
126
+ expect(search['errorsOnly']).toBe(false)
127
+ })
128
+ expect(screen.getByRole('button', { name: 'Filters' })).toBeDefined()
129
+ })
130
+ })
131
+
132
+ describe('the mobile inline budget is per-slot data, not a second mount', () => {
133
+ test('inline defaults to 1 — the first slot only', async () => {
134
+ await mountSet('/dashboard')
135
+ expect(slots().map((slot) => slot.hasAttribute('data-inline'))).toEqual([
136
+ true,
137
+ false,
138
+ false,
139
+ false,
140
+ ])
141
+ })
142
+
143
+ test('inline={2} keeps the first two', async () => {
144
+ await mountSet('/dashboard', 2)
145
+ expect(slots().map((slot) => slot.hasAttribute('data-inline'))).toEqual([
146
+ true,
147
+ true,
148
+ false,
149
+ false,
150
+ ])
151
+ })
152
+
153
+ test('every child stays mounted regardless of the budget — the row is the whole census', async () => {
154
+ await mountSet('/dashboard')
155
+ expect(slots()).toHaveLength(4)
156
+ })
157
+ })
158
+
159
+ describe('the desktop fold is measured, and it never scrolls', () => {
160
+ const offsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth')
161
+ const clientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
162
+ let observers: (() => void)[] = []
163
+
164
+ function stubLayout(): void {
165
+ observers = []
166
+ Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
167
+ configurable: true,
168
+ get: widthFromData,
169
+ })
170
+ Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
171
+ configurable: true,
172
+ get: widthFromData,
173
+ })
174
+ class ResizeObserverStub {
175
+ constructor(callback: () => void) {
176
+ observers.push(callback)
177
+ }
178
+ observe(): void {}
179
+ unobserve(): void {}
180
+ disconnect(): void {}
181
+ }
182
+ window.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver
183
+ }
184
+
185
+ afterEach(() => {
186
+ if (offsetWidth !== undefined) {
187
+ Object.defineProperty(HTMLElement.prototype, 'offsetWidth', offsetWidth)
188
+ }
189
+ if (clientWidth !== undefined) {
190
+ Object.defineProperty(HTMLElement.prototype, 'clientWidth', clientWidth)
191
+ }
192
+ })
193
+
194
+ async function resizeTo(available: number, perSlot: number): Promise<void> {
195
+ row().dataset['testWidth'] = String(available)
196
+ for (const slot of slots()) slot.dataset['testWidth'] = String(perSlot)
197
+ await act(async () => {
198
+ for (const notify of observers) notify()
199
+ })
200
+ }
201
+
202
+ test('four 100px pills in 250px: the tail folds into +3, and the row keeps one', async () => {
203
+ stubLayout()
204
+ await mountSet('/dashboard')
205
+ await resizeTo(250, 100)
206
+
207
+ expect(slots().map((slot) => slot.hasAttribute('data-folded'))).toEqual([
208
+ false,
209
+ true,
210
+ true,
211
+ true,
212
+ ])
213
+ const fold = screen.getByRole('button', { name: '3 more filters' })
214
+ expect(fold.hasAttribute('data-shown')).toBe(true)
215
+ })
216
+
217
+ test('slots of DIFFERENT widths fold at the right index, not at a uniform one', async () => {
218
+ // The uniform-100px case can be satisfied by arithmetic that ignores which slot is which. Here
219
+ // the widths are 40/40/300/40: the wide third pill is what pushes the row over, so the fold has
220
+ // to land at 2 rather than at some average-derived index.
221
+ stubLayout()
222
+ await mountSet('/dashboard')
223
+ row().dataset['testWidth'] = '250'
224
+ const widths = ['40', '40', '300', '40']
225
+ slots().forEach((slot, index) => {
226
+ slot.dataset['testWidth'] = widths[index] ?? '40'
227
+ })
228
+ await act(async () => {
229
+ for (const notify of observers) notify()
230
+ })
231
+
232
+ expect(slots().map((slot) => slot.hasAttribute('data-folded'))).toEqual([
233
+ false,
234
+ false,
235
+ true,
236
+ true,
237
+ ])
238
+ expect(screen.getByRole('button', { name: '2 more filters' }).hasAttribute('data-shown')).toBe(
239
+ true,
240
+ )
241
+ })
242
+
243
+ test('the same four pills in 900px: nothing folds and the +N pill stays unshown', async () => {
244
+ stubLayout()
245
+ await mountSet('/dashboard')
246
+ await resizeTo(900, 100)
247
+
248
+ expect(slots().some((slot) => slot.hasAttribute('data-folded'))).toBe(false)
249
+ expect(screen.getByRole('button', { name: '0 more filters' }).hasAttribute('data-shown')).toBe(
250
+ false,
251
+ )
252
+ })
253
+ })
254
+
255
+ describe('a folded filter is still operable from inside the +N dropdown', () => {
256
+ const offsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth')
257
+ const clientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
258
+
259
+ afterEach(() => {
260
+ if (offsetWidth !== undefined) {
261
+ Object.defineProperty(HTMLElement.prototype, 'offsetWidth', offsetWidth)
262
+ }
263
+ if (clientWidth !== undefined) {
264
+ Object.defineProperty(HTMLElement.prototype, 'clientWidth', clientWidth)
265
+ }
266
+ })
267
+
268
+ test('opening +N and changing a folded field writes the URL, and +N stays open', async () => {
269
+ // The regression this pins: rendering the fold body in PILL form gave each folded filter its own
270
+ // `withinPortal` Popover, whose mousedown is outside the +N dropdown's `composedPath()` — so +N
271
+ // closed, the pill unmounted, and the click never landed. The sheet form has no nested overlay.
272
+ let observers: (() => void)[] = []
273
+ Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
274
+ configurable: true,
275
+ get: widthFromData,
276
+ })
277
+ Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
278
+ configurable: true,
279
+ get: widthFromData,
280
+ })
281
+ class ResizeObserverStub {
282
+ constructor(callback: () => void) {
283
+ observers.push(callback)
284
+ }
285
+ observe(): void {}
286
+ unobserve(): void {}
287
+ disconnect(): void {}
288
+ }
289
+ window.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver
290
+
291
+ const router = await mountSet('/dashboard')
292
+ row().dataset['testWidth'] = '250'
293
+ for (const slot of slots()) slot.dataset['testWidth'] = '100'
294
+ await act(async () => {
295
+ for (const notify of observers) notify()
296
+ })
297
+
298
+ await act(async () => {
299
+ fireEvent.click(screen.getByRole('button', { name: '3 more filters' }))
300
+ })
301
+
302
+ // The folded children render their sheet form — a named option list, no second pill. `group`,
303
+ // not `radiogroup`: the sheet form is a `<fieldset>` of native radios (`SheetOptionList`), which
304
+ // is the grouping role a fieldset carries. `Radio.Group` is the POPOVER form's shape.
305
+ const group = screen.getByRole('group', { name: 'Region', hidden: true })
306
+ expect(group).toBeDefined()
307
+
308
+ await act(async () => {
309
+ fireEvent.click(screen.getByRole('radio', { name: 'eu', hidden: true }))
310
+ })
311
+
312
+ await waitFor(() => {
313
+ expect((router.state.location.search as Record<string, unknown>)['region']).toBe('eu')
314
+ })
315
+ // Still open: the write did not tear its own container down.
316
+ expect(screen.getByRole('group', { name: 'Region', hidden: true })).toBeDefined()
317
+ observers = []
318
+ })
319
+ })
320
+
321
+ describe('the sheet names every control it holds', () => {
322
+ test('the option lists and the Switch row carry the filter name, not the pill text', async () => {
323
+ await mountSet('/dashboard?currency=EUR')
324
+ await act(async () => {
325
+ fireEvent.click(screen.getByRole('button', { name: 'Filters (1)' }))
326
+ })
327
+
328
+ // `Currency`, not `EUR` — the pill reads the VALUE once set, which names the selection rather
329
+ // than the filter. Each `<fieldset>` points at its own visible SheetField heading.
330
+ for (const name of ['Currency', 'Region', 'Tier']) {
331
+ expect(screen.getByRole('group', { name, hidden: true })).toBeDefined()
332
+ }
333
+ expect(screen.getByRole('switch', { name: 'Errors only', hidden: true })).toBeDefined()
334
+ })
335
+ })
336
+
337
+ /**
338
+ * The 44px touch target, asserted at the SOURCE. Neither half is checkable in the DOM here:
339
+ * happy-dom lays nothing out (so no bounding box), and a `.module.css` import resolves to `undefined`
340
+ * under `bun test` (so the emitted `class` attribute carries only Mantine's own names). What IS
341
+ * checkable is the pair that produces it — the CSS stretching the body and label to the row, and
342
+ * every row-rendering control actually passing those classes. A row whose height sits on a
343
+ * non-interactive wrapper is C15's number without C15's behaviour, which is the defect this pins.
344
+ */
345
+ describe('sheet rows are tappable across their full height (C15)', () => {
346
+ const css = readFileSync(join(import.meta.dir, 'controls.module.css'), 'utf8')
347
+
348
+ function block(selector: string): string {
349
+ const start = css.indexOf(`${selector} {`)
350
+ expect(start).toBeGreaterThan(-1)
351
+ return css.slice(start, css.indexOf('}', start))
352
+ }
353
+
354
+ test('both row primitives pin the height to the sheet-row token, not a literal', () => {
355
+ expect(block('.sheetRow')).toContain('var(--vx-space-sheet-row-height')
356
+ expect(block('.sheetOption')).toContain('var(--vx-space-sheet-row-height')
357
+ })
358
+
359
+ test('the option row IS the hit target — the height sits on the label, never on a wrapper', () => {
360
+ // `.sheetOption` is the `<label>` (and, in a disclosure, the `<button>`) itself, so the 44px is
361
+ // on the interactive element by construction rather than by a classNames hand-off.
362
+ const rule = block('.sheetOption')
363
+ expect(rule).toContain('width: 100%')
364
+ expect(rule).toContain('cursor: pointer')
365
+ })
366
+
367
+ test('.sheetRowBody and .sheetRowLabel stretch the interactive element to that height', () => {
368
+ for (const selector of ['.sheetRowBody', '.sheetRowLabel']) {
369
+ const rule = block(selector)
370
+ expect(rule).toContain('flex: 1')
371
+ expect(rule).toContain('min-height: inherit')
372
+ }
373
+ })
374
+
375
+ test('every control that renders a sheet row goes through a 44px row primitive', () => {
376
+ // Two primitives, one law. `ToggleFilter` renders a Mantine `Switch` AS the row, so it needs
377
+ // `sheetRowClassNames` to stretch the control's own `<label>` across the 44px. The enum/multi/
378
+ // range filters render `SheetOptionList`, which owns the `<label>`-per-row box itself — the
379
+ // classNames escape exists for controls basalt does not draw the row for.
380
+ expect(readFileSync(join(import.meta.dir, 'toggle-filter.tsx'), 'utf8')).toContain(
381
+ 'sheetRowClassNames',
382
+ )
383
+ for (const file of ['enum-filter.tsx', 'multi-select-filter.tsx', 'range-filter.tsx']) {
384
+ const source = readFileSync(join(import.meta.dir, file), 'utf8')
385
+ expect(source).toContain('SheetOptionList')
386
+ }
387
+ })
388
+
389
+ test('no control wraps its sheet row in a plain div — the height must sit on the label', () => {
390
+ // `SheetRow` puts `min-height` on a non-interactive wrapper, which is exactly the 20px-strip
391
+ // defect. It stays exported for a future non-interactive row; no filter may use it.
392
+ for (const file of ['enum-filter.tsx', 'multi-select-filter.tsx', 'toggle-filter.tsx']) {
393
+ const source = readFileSync(join(import.meta.dir, file), 'utf8')
394
+ expect(source).not.toContain('<SheetRow>')
395
+ }
396
+ })
397
+ })
398
+
399
+ describe('an empty home renders nothing (C14)', () => {
400
+ test('no children → no row, so no route pays for a reserved bar', async () => {
401
+ const rootRoute = createRootRoute({ component: () => <Outlet /> })
402
+ const pageRoute = createRoute({
403
+ getParentRoute: () => rootRoute,
404
+ path: '/dashboard',
405
+ component: (): ReactNode => <FilterSet>{null}</FilterSet>,
406
+ })
407
+ const router = createRouter({
408
+ routeTree: rootRoute.addChildren([pageRoute]),
409
+ history: createMemoryHistory({ initialEntries: ['/dashboard'] }),
410
+ })
411
+ render(
412
+ <MantineProvider>
413
+ <RouterProvider router={router} />
414
+ </MantineProvider>,
415
+ )
416
+ await waitFor(() => {
417
+ expect(router.state.status).toBe('idle')
418
+ })
419
+
420
+ expect(document.querySelector('[data-filter-row]')).toBeNull()
421
+ })
422
+ })
423
+
424
+ /**
425
+ * The `Filters (n)` pill is the mobile SHEET TRIGGER, and the sheet only ever holds what the
426
+ * `inline` budget folded away. With nothing folded it opens a drawer showing a copy of the pills
427
+ * already on screen — while costing the mobile row the width of a pill to say so.
428
+ */
429
+ describe('the mobile sheet trigger appears only when a child is folded', () => {
430
+ async function mountOne(inline?: number): Promise<void> {
431
+ const rootRoute = createRootRoute({ component: () => <Outlet /> })
432
+ const pageRoute = createRoute({
433
+ getParentRoute: () => rootRoute,
434
+ path: '/dashboard',
435
+ validateSearch: store.validateSearch,
436
+ component: (): ReactNode => (
437
+ <FilterSet {...(inline !== undefined && { inline })}>
438
+ <SelectFilter field={store.field.currency} label="Currency" />
439
+ </FilterSet>
440
+ ),
441
+ })
442
+ const router = createRouter({
443
+ routeTree: rootRoute.addChildren([pageRoute]),
444
+ history: createMemoryHistory({ initialEntries: ['/dashboard'] }),
445
+ })
446
+ render(
447
+ <MantineProvider>
448
+ <RouterProvider router={router} />
449
+ </MantineProvider>,
450
+ )
451
+ await waitFor(() => {
452
+ expect(router.state.status).toBe('idle')
453
+ })
454
+ }
455
+
456
+ test('one child at the default budget → no Filters pill at all', async () => {
457
+ await mountOne()
458
+ expect(screen.queryByRole('button', { name: 'Filters' })).toBeNull()
459
+ // …and the filter itself is still there, inline.
460
+ expect(slots()).toHaveLength(1)
461
+ })
462
+
463
+ test('inline={0} folds that same child → the pill is back', async () => {
464
+ await mountOne(0)
465
+ expect(screen.getByRole('button', { name: 'Filters' })).toBeDefined()
466
+ })
467
+
468
+ test('four children at the default budget keep the pill', async () => {
469
+ await mountSet('/dashboard')
470
+ expect(screen.getByRole('button', { name: 'Filters' })).toBeDefined()
471
+ })
472
+ })
@@ -0,0 +1,225 @@
1
+ /**
2
+ * `FilterSet` — the filter home's container (`docs/CONTROLS-SPEC.md` §2.1 row 2, §3). It owns the
3
+ * three things a consumer used to hand-roll per page: the nowrap row, the overflow fold, and the
4
+ * mobile sheet.
5
+ *
6
+ * **Desktop.** One row, `wrap: nowrap`, and it NEVER scrolls (C7). When the measured pill widths
7
+ * exceed the container the tail folds into a `+N` menu pill — a `ResizeObserver` on the row plus
8
+ * per-slot `offsetWidth`, because the fold point depends on the labels actually painted (the same
9
+ * reasoning as the charts' `autoMargin`), not on a guessed pill width.
10
+ *
11
+ * **Mobile (`< sm`).** The first `inline` children stay pills; every child — inline ones included —
12
+ * also renders its full-width sheet form inside the `Filters (n)` Drawer — which exists only when
13
+ * the budget folded at least one child away (one filter at `inline: 1` gets no pill at all). `n` is
14
+ * DERIVED: each filter registers `{ isActive, reset }` with this component (`filter-context.tsx`),
15
+ * so `FilterSet` never needs to be told which fields its children hold, and `Reset all` reaches all
16
+ * of them.
17
+ *
18
+ * **Two mounts of the same children, one census.** The row keeps every child mounted on every
19
+ * viewport (hidden slots are `display: none`, never unmounted), so it is the single registration
20
+ * surface; the fold dropdown and the sheet mount a second copy under a `null` registry. That is why
21
+ * a folded filter still counts toward `n` and still answers `Reset all`.
22
+ *
23
+ * @example
24
+ * <PageBar
25
+ * filters={
26
+ * <FilterSet>
27
+ * <RangeFilter field={analytics.field.range} customPicker={DateRangePicker} />
28
+ * <CompareFilter field={analytics.field.compare} />
29
+ * <SelectFilter field={analytics.field.currency} label="Currency" />
30
+ * </FilterSet>
31
+ * }
32
+ * />
33
+ */
34
+ import { Children, isValidElement, useCallback, useEffect, useRef, useState } from 'react'
35
+ import type { ReactNode, RefObject } from 'react'
36
+ import classes from './controls.module.css'
37
+ import { FilterSetScope, useFilterCensus } from './filter-context'
38
+ import { FilterPill } from './filter-pill'
39
+ import { FilterSheet } from './filter-sheet'
40
+ import { FunnelGlyph } from './glyphs'
41
+
42
+ /** Width reserved for the `+N` pill once folding is known to be necessary. */
43
+ const FOLD_PILL_WIDTH = 52
44
+ /** `--vx-space-control-gap`'s level-0 value — the fallback when the row has no computed style. */
45
+ const FALLBACK_GAP = 6
46
+
47
+ export type FilterSetProps = {
48
+ readonly children: ReactNode
49
+ /**
50
+ * How many children stay inline pills below `sm`. The rest are reachable only through the
51
+ * `Filters (n)` sheet — which always holds every child, inline ones included.
52
+ *
53
+ * @default 1
54
+ */
55
+ readonly inline?: number
56
+ }
57
+
58
+ export function FilterSet({ children, inline = 1 }: FilterSetProps): ReactNode {
59
+ const items = Children.toArray(children)
60
+ const { registry, activeCount, resetAll } = useFilterCensus()
61
+ const [sheetOpened, setSheetOpened] = useState(false)
62
+ const rowRef = useRef<HTMLDivElement | null>(null)
63
+ const fit = useOverflowFit(rowRef, items.length)
64
+
65
+ const openSheet = useCallback(() => {
66
+ setSheetOpened(true)
67
+ }, [])
68
+ const closeSheet = useCallback(() => {
69
+ setSheetOpened(false)
70
+ }, [])
71
+
72
+ // C14 — an empty home renders nothing, so no route pays for a reserved row.
73
+ if (items.length === 0) return null
74
+
75
+ const folded = items.slice(fit)
76
+ // Below `sm` the sheet is the ONLY way to reach a child past the `inline` budget — so with every
77
+ // child already inline (one filter at the default budget) it opens a drawer holding a copy of
78
+ // what is on screen, under a pill that costs the row its width for nothing. The trigger exists
79
+ // when something is actually folded away, and not before (law C14's reading of the mobile row).
80
+ const anyFoldedOnMobile = items.length > inline
81
+
82
+ return (
83
+ <>
84
+ <div className={classes.pillRow} ref={rowRef} data-filter-row>
85
+ <FilterSetScope surface="pill" registry={registry}>
86
+ {items.map((child, index) => (
87
+ // `Children.toArray` already stamped a stable `.0`/`.1`-style key on every child, so
88
+ // the slot wrapper reuses it rather than inventing an array-index key.
89
+ <div
90
+ key={slotKey(child, index)}
91
+ className={classes.pillSlot}
92
+ data-filter-slot
93
+ {...(index < inline && { 'data-inline': true })}
94
+ {...(index >= fit && { 'data-folded': true })}
95
+ >
96
+ {child}
97
+ </div>
98
+ ))}
99
+ </FilterSetScope>
100
+
101
+ <FilterPill
102
+ label={`+${folded.length}`}
103
+ ariaLabel={`${folded.length} more filters`}
104
+ className={classes.foldPill}
105
+ hideGlyph
106
+ shown={folded.length > 0}
107
+ >
108
+ <div className={classes.optionList}>
109
+ {/* SHEET form, not pill form. A folded pill would open a SECOND `withinPortal`
110
+ Popover, which is not a DOM descendant of this one — Mantine's click-outside check
111
+ walks `composedPath()` against the outer target/dropdown only, so the inner pill's
112
+ own mousedown reads as "outside", closes `+N`, unmounts the pill, and the click
113
+ never lands. The sheet form has no nested overlay at all, and it is the same form
114
+ the mobile `Filters (n)` Drawer already uses — one behaviour, not two. */}
115
+ <FilterSetScope surface="sheet" registry={null}>
116
+ {folded}
117
+ </FilterSetScope>
118
+ </div>
119
+ </FilterPill>
120
+
121
+ {anyFoldedOnMobile && (
122
+ <FilterPill
123
+ label={activeCount > 0 ? `Filters (${activeCount})` : 'Filters'}
124
+ active={activeCount > 0}
125
+ icon={<FunnelGlyph />}
126
+ className={classes.sheetPill}
127
+ hideGlyph
128
+ onClick={openSheet}
129
+ />
130
+ )}
131
+ </div>
132
+
133
+ {anyFoldedOnMobile && (
134
+ <FilterSheet opened={sheetOpened} onClose={closeSheet} onResetAll={resetAll}>
135
+ <FilterSetScope surface="sheet" registry={null}>
136
+ {items}
137
+ </FilterSetScope>
138
+ </FilterSheet>
139
+ )}
140
+ </>
141
+ )
142
+ }
143
+
144
+ /**
145
+ * How many of `count` slots fit the row at its current width. Measures the slots' own
146
+ * `offsetWidth` (remembering the last non-zero value per slot, since a folded or `inline`-budgeted
147
+ * slot measures 0) and re-runs on every `ResizeObserver` tick.
148
+ *
149
+ * The measurement is only honest because `.pillSlot` is `flex-shrink: 0` — see that rule's comment
150
+ * in `controls.module.css`. Every SLOT is observed alongside the row, not just the row: a label
151
+ * that grows (a preset becoming `Mar 1 – Mar 14`) changes no container width, so observing the row
152
+ * alone would miss exactly the case that pushes a fitting row over its budget.
153
+ */
154
+ function useOverflowFit(rowRef: RefObject<HTMLDivElement | null>, count: number): number {
155
+ const [fit, setFit] = useState(count)
156
+ const widths = useRef<number[]>([])
157
+
158
+ useEffect(() => {
159
+ const row = rowRef.current
160
+ if (row === null) return
161
+ if (typeof ResizeObserver === 'undefined') {
162
+ setFit(count)
163
+ return
164
+ }
165
+
166
+ const measure = (): void => {
167
+ const slots = [...row.querySelectorAll<HTMLElement>('[data-filter-slot]')]
168
+ slots.forEach((slot, index) => {
169
+ const width = slot.offsetWidth
170
+ if (width > 0) widths.current[index] = width
171
+ })
172
+
173
+ const available = row.clientWidth
174
+ if (available <= 0) {
175
+ setFit(count)
176
+ return
177
+ }
178
+
179
+ const gap = readGap(row)
180
+ const bare = fitCount(widths.current, count, gap, available, 0)
181
+ // Reserve the `+N` pill only once folding is known to be needed — reserving it up front
182
+ // would fold a set that fits exactly.
183
+ setFit(
184
+ bare === count ? count : fitCount(widths.current, count, gap, available, FOLD_PILL_WIDTH),
185
+ )
186
+ }
187
+
188
+ measure()
189
+ const observer = new ResizeObserver(measure)
190
+ observer.observe(row)
191
+ for (const slot of row.querySelectorAll<HTMLElement>('[data-filter-slot]')) {
192
+ observer.observe(slot)
193
+ }
194
+ return () => {
195
+ observer.disconnect()
196
+ }
197
+ }, [rowRef, count])
198
+
199
+ return fit
200
+ }
201
+
202
+ function fitCount(
203
+ widths: readonly number[],
204
+ count: number,
205
+ gap: number,
206
+ available: number,
207
+ reserve: number,
208
+ ): number {
209
+ let used = reserve === 0 ? 0 : reserve + gap
210
+ for (let index = 0; index < count; index += 1) {
211
+ used += (index === 0 ? 0 : gap) + (widths[index] ?? 0)
212
+ if (used > available) return index
213
+ }
214
+ return count
215
+ }
216
+
217
+ function slotKey(child: ReactNode, index: number): string {
218
+ if (isValidElement(child) && child.key !== null) return child.key
219
+ return String(index)
220
+ }
221
+
222
+ function readGap(row: HTMLElement): number {
223
+ const parsed = Number.parseFloat(getComputedStyle(row).columnGap)
224
+ return Number.isFinite(parsed) ? parsed : FALLBACK_GAP
225
+ }