basalt-ui 1.24.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 (467) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +25 -0
  3. package/MIGRATING.md +547 -21
  4. package/README.md +46 -33
  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/query-state.d.ts +1 -1
  134. package/dist/dashboard/settings-section.d.ts +6 -4
  135. package/dist/dashboard/settings-section.d.ts.map +1 -1
  136. package/dist/dashboard/settings-section.js +23 -16
  137. package/dist/dashboard/settings-section.module.css +10 -0
  138. package/dist/dashboard/settings-section.module.css.d.ts +1 -0
  139. package/dist/dashboard/settings-section.test.d.ts +2 -0
  140. package/dist/dashboard/settings-section.test.d.ts.map +1 -0
  141. package/dist/dashboard/stat-card.d.ts +44 -9
  142. package/dist/dashboard/stat-card.d.ts.map +1 -1
  143. package/dist/dashboard/stat-card.js +80 -48
  144. package/dist/dashboard/stat-card.module.css +82 -0
  145. package/dist/dashboard/stat-card.module.css.d.ts +11 -0
  146. package/dist/data/data-table.d.ts +102 -20
  147. package/dist/data/data-table.d.ts.map +1 -1
  148. package/dist/data/data-table.js +166 -65
  149. package/dist/data/table.d.ts +1 -1
  150. package/dist/data/table.d.ts.map +1 -1
  151. package/dist/guard/grace.test.d.ts +20 -0
  152. package/dist/guard/grace.test.d.ts.map +1 -0
  153. package/dist/guard/index.d.ts +66 -3
  154. package/dist/guard/index.d.ts.map +1 -1
  155. package/dist/guard/index.js +82 -7
  156. package/dist/guard/types.d.ts +3 -3
  157. package/dist/guard/types.d.ts.map +1 -1
  158. package/dist/index.d.ts +12 -2
  159. package/dist/index.d.ts.map +1 -1
  160. package/dist/index.js +13 -10
  161. package/dist/nav/types.d.ts +78 -2
  162. package/dist/nav/types.d.ts.map +1 -1
  163. package/dist/notifications/bell.d.ts +4 -1
  164. package/dist/notifications/bell.d.ts.map +1 -1
  165. package/dist/notifications/index.d.ts +1 -1
  166. package/dist/router-tanstack/field.d.ts +14 -0
  167. package/dist/router-tanstack/field.d.ts.map +1 -0
  168. package/dist/router-tanstack/field.js +4 -0
  169. package/dist/router-tanstack/index.d.ts +6 -2
  170. package/dist/router-tanstack/index.d.ts.map +1 -1
  171. package/dist/router-tanstack/index.js +4 -0
  172. package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
  173. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  174. package/dist/router-tanstack/multi-search-param-store.js +19 -27
  175. package/dist/router-tanstack/search-param-store.d.ts +3 -87
  176. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  177. package/dist/router-tanstack/search-param-store.js +16 -45
  178. package/dist/router-tanstack/search-store.d.ts +81 -0
  179. package/dist/router-tanstack/search-store.d.ts.map +1 -0
  180. package/dist/router-tanstack/search-store.js +187 -0
  181. package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
  182. package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
  183. package/dist/router-tanstack/search-store.test.d.ts +2 -0
  184. package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
  185. package/dist/router-tanstack/use-nav.d.ts +0 -2
  186. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  187. package/dist/router-tanstack/use-nav.js +34 -12
  188. package/dist/router-tanstack/use-nav.test.d.ts +2 -0
  189. package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
  190. package/dist/section/index.d.ts +2 -0
  191. package/dist/section/index.d.ts.map +1 -0
  192. package/dist/section/index.js +4 -0
  193. package/dist/section/section.d.ts +24 -0
  194. package/dist/section/section.d.ts.map +1 -0
  195. package/dist/section/section.js +126 -0
  196. package/dist/section/section.module.css +104 -0
  197. package/dist/section/section.module.css.d.ts +11 -0
  198. package/dist/section/section.test.d.ts +2 -0
  199. package/dist/section/section.test.d.ts.map +1 -0
  200. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  201. package/dist/shell/app-breadcrumbs.js +18 -15
  202. package/dist/shell/app-header.module.css +77 -37
  203. package/dist/shell/app-header.module.css.d.ts +2 -1
  204. package/dist/shell/app-mobile-nav.d.ts +9 -2
  205. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  206. package/dist/shell/app-mobile-nav.js +98 -11
  207. package/dist/shell/app-mobile-nav.module.css +8 -0
  208. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  209. package/dist/shell/app-sidebar.d.ts +55 -28
  210. package/dist/shell/app-sidebar.d.ts.map +1 -1
  211. package/dist/shell/app-sidebar.js +116 -67
  212. package/dist/shell/app-sidebar.module.css +303 -20
  213. package/dist/shell/app-sidebar.module.css.d.ts +19 -1
  214. package/dist/shell/index.d.ts +55 -21
  215. package/dist/shell/index.d.ts.map +1 -1
  216. package/dist/shell/index.js +40 -18
  217. package/dist/shell/mobile-nav-model.d.ts +13 -2
  218. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  219. package/dist/shell/mobile-nav-model.js +15 -1
  220. package/dist/shell/nav-count-badge.d.ts +1 -1
  221. package/dist/shell/page-bar.d.ts +51 -0
  222. package/dist/shell/page-bar.d.ts.map +1 -0
  223. package/dist/shell/page-bar.js +154 -0
  224. package/dist/shell/page-bar.module.css +178 -0
  225. package/dist/shell/page-bar.module.css.d.ts +17 -0
  226. package/dist/shell/page-bar.test.d.ts +2 -0
  227. package/dist/shell/page-bar.test.d.ts.map +1 -0
  228. package/dist/shell/sidebar-block-model.d.ts +48 -0
  229. package/dist/shell/sidebar-block-model.d.ts.map +1 -0
  230. package/dist/shell/sidebar-block-model.js +39 -0
  231. package/dist/shell/sidebar-blocks.d.ts +32 -0
  232. package/dist/shell/sidebar-blocks.d.ts.map +1 -0
  233. package/dist/shell/sidebar-blocks.js +169 -0
  234. package/dist/shell/sidebar-search.d.ts +13 -1
  235. package/dist/shell/sidebar-search.d.ts.map +1 -1
  236. package/dist/shell/sidebar-search.js +34 -1
  237. package/dist/shell/sidebar-search.module.css +26 -0
  238. package/dist/shell/sidebar-search.module.css.d.ts +2 -0
  239. package/dist/state/fields.d.ts +392 -0
  240. package/dist/state/fields.d.ts.map +1 -0
  241. package/dist/state/fields.js +369 -0
  242. package/dist/state/fields.test.d.ts +2 -0
  243. package/dist/state/fields.test.d.ts.map +1 -0
  244. package/dist/state/persisted.d.ts +48 -0
  245. package/dist/state/persisted.d.ts.map +1 -0
  246. package/dist/state/persisted.js +105 -0
  247. package/dist/state.d.ts +11 -56
  248. package/dist/state.d.ts.map +1 -1
  249. package/dist/state.js +14 -120
  250. package/dist/styles.css +13 -0
  251. package/dist/surfaces.d.ts +106 -24
  252. package/dist/surfaces.d.ts.map +1 -1
  253. package/dist/surfaces.js +155 -19
  254. package/dist/theme/controls.module.css +16 -1
  255. package/dist/theme/ctl-theme.d.ts +32 -0
  256. package/dist/theme/ctl-theme.d.ts.map +1 -0
  257. package/dist/theme/ctl-theme.js +54 -0
  258. package/dist/theme/ctl-theme.test.d.ts +2 -0
  259. package/dist/theme/ctl-theme.test.d.ts.map +1 -0
  260. package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
  261. package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
  262. package/dist/theme/divider-law.test.d.ts +2 -0
  263. package/dist/theme/divider-law.test.d.ts.map +1 -0
  264. package/dist/theme/icon-slot.d.ts +44 -0
  265. package/dist/theme/icon-slot.d.ts.map +1 -0
  266. package/dist/theme/icon-slot.js +21 -0
  267. package/dist/theme/icon-slot.module.css +47 -0
  268. package/dist/theme/icon-slot.module.css.d.ts +8 -0
  269. package/dist/theme/icon-slot.test.d.ts +2 -0
  270. package/dist/theme/icon-slot.test.d.ts.map +1 -0
  271. package/dist/theme/index.d.ts +1 -0
  272. package/dist/theme/index.d.ts.map +1 -1
  273. package/dist/theme/index.js +82 -5
  274. package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
  275. package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
  276. package/dist/theme/segmented-control.module.css +20 -0
  277. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  278. package/dist/tokens/index.d.ts +12 -0
  279. package/dist/tokens/index.d.ts.map +1 -1
  280. package/dist/tokens/index.js +38 -8
  281. package/dist/tokens/palette.d.ts +97 -61
  282. package/dist/tokens/palette.d.ts.map +1 -1
  283. package/dist/tokens/palette.js +76 -38
  284. package/dist/tokens.css +10 -2
  285. package/dist/widget-header/delta-badge.d.ts +13 -0
  286. package/dist/widget-header/delta-badge.d.ts.map +1 -0
  287. package/dist/widget-header/delta-badge.js +29 -0
  288. package/dist/widget-header/delta-badge.test.d.ts +2 -0
  289. package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
  290. package/dist/widget-header/index.d.ts +3 -0
  291. package/dist/widget-header/index.d.ts.map +1 -0
  292. package/dist/widget-header/index.js +6 -0
  293. package/dist/widget-header/no-mantine.test.d.ts +2 -0
  294. package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
  295. package/dist/widget-header/widget-header.d.ts +37 -0
  296. package/dist/widget-header/widget-header.d.ts.map +1 -0
  297. package/dist/widget-header/widget-header.js +77 -0
  298. package/dist/widget-header/widget-header.module.css +335 -0
  299. package/dist/widget-header/widget-header.module.css.d.ts +24 -0
  300. package/dist/widget-header/widget-header.test.d.ts +2 -0
  301. package/dist/widget-header/widget-header.test.d.ts.map +1 -0
  302. package/llms.txt +30 -17
  303. package/package.json +14 -1
  304. package/src/charts/cursor/resolve.test.ts +29 -1
  305. package/src/charts/cursor/resolve.ts +38 -6
  306. package/src/charts/cursor/scope.tsx +6 -3
  307. package/src/charts/cursor/store.test.ts +19 -1
  308. package/src/charts/cursor/store.ts +10 -5
  309. package/src/charts/hooks/useChartCursor.ts +16 -8
  310. package/src/charts/index.ts +2 -2
  311. package/src/charts/primitives/CartesianChart.test.tsx +124 -0
  312. package/src/charts/primitives/ChartCard.test.tsx +66 -0
  313. package/src/charts/primitives/ChartCard.tsx +86 -146
  314. package/src/charts/sparklines/BarSparkline.tsx +43 -5
  315. package/src/cli/check-coverage.test.ts +297 -0
  316. package/src/cli/check-theme.test.ts +38 -14
  317. package/src/cli/index.ts +392 -16
  318. package/src/cli/init-sync.test.ts +97 -0
  319. package/src/connectivity/connectivity-indicator.tsx +16 -9
  320. package/src/content/index.ts +16 -3
  321. package/src/content/prose.module.css +7 -28
  322. package/src/content/prose.module.css.test.ts +8 -24
  323. package/src/controls/actions.module.css +62 -0
  324. package/src/controls/actions.module.css.d.ts +13 -0
  325. package/src/controls/actions.test.tsx +252 -0
  326. package/src/controls/actions.tsx +655 -0
  327. package/src/controls/actions.type-guard.test.ts +63 -0
  328. package/src/controls/compare-filter.tsx +64 -0
  329. package/src/controls/control-group.test.tsx +229 -0
  330. package/src/controls/control-group.tsx +63 -0
  331. package/src/controls/controls.module.css +542 -0
  332. package/src/controls/controls.module.css.d.ts +35 -0
  333. package/src/controls/controls.router.test.tsx +637 -0
  334. package/src/controls/enum-filter.tsx +159 -0
  335. package/src/controls/filter-context.tsx +153 -0
  336. package/src/controls/filter-pill.tsx +120 -0
  337. package/src/controls/filter-set.test.tsx +472 -0
  338. package/src/controls/filter-set.tsx +225 -0
  339. package/src/controls/filter-sheet.tsx +285 -0
  340. package/src/controls/glyphs.tsx +81 -0
  341. package/src/controls/index.ts +48 -0
  342. package/src/controls/multi-select-filter.tsx +140 -0
  343. package/src/controls/peer-boundary.test.ts +85 -0
  344. package/src/controls/props-contract.test.tsx +106 -0
  345. package/src/controls/range-filter.tsx +184 -0
  346. package/src/controls/search-filter.tsx +123 -0
  347. package/src/controls/select-filter.tsx +89 -0
  348. package/src/controls/sync-button.module.css +63 -0
  349. package/src/controls/sync-button.module.css.d.ts +11 -0
  350. package/src/controls/sync-button.test.tsx +183 -0
  351. package/src/controls/sync-button.tsx +197 -0
  352. package/src/controls/toggle-filter.tsx +60 -0
  353. package/src/controls/view-tabs.tsx +110 -0
  354. package/src/controls-dates/date-range-picker.tsx +42 -0
  355. package/src/controls-dates/index.ts +8 -0
  356. package/src/dashboard/delta-badge.tsx +4 -74
  357. package/src/dashboard/query-state.tsx +1 -1
  358. package/src/dashboard/settings-section.module.css +10 -0
  359. package/src/dashboard/settings-section.module.css.d.ts +1 -0
  360. package/src/dashboard/settings-section.test.tsx +56 -0
  361. package/src/dashboard/settings-section.tsx +33 -30
  362. package/src/dashboard/stat-card.module.css +82 -0
  363. package/src/dashboard/stat-card.module.css.d.ts +11 -0
  364. package/src/dashboard/stat-card.test.tsx +67 -1
  365. package/src/dashboard/stat-card.tsx +209 -68
  366. package/src/data/data-table.test.tsx +282 -6
  367. package/src/data/data-table.tsx +429 -94
  368. package/src/data/table.ts +2 -0
  369. package/src/guard/check-source.test.ts +126 -13
  370. package/src/guard/grace.test.ts +149 -0
  371. package/src/guard/index.ts +172 -29
  372. package/src/guard/types.ts +4 -2
  373. package/src/index.ts +46 -5
  374. package/src/nav/types.ts +84 -2
  375. package/src/notifications/bell.tsx +6 -3
  376. package/src/notifications/index.ts +1 -1
  377. package/src/router-tanstack/field.ts +31 -0
  378. package/src/router-tanstack/index.ts +24 -2
  379. package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
  380. package/src/router-tanstack/multi-search-param-store.ts +46 -77
  381. package/src/router-tanstack/search-param-store.ts +42 -159
  382. package/src/router-tanstack/search-store.router.test.tsx +661 -0
  383. package/src/router-tanstack/search-store.test.ts +418 -0
  384. package/src/router-tanstack/search-store.ts +380 -0
  385. package/src/router-tanstack/use-nav.test.tsx +187 -0
  386. package/src/router-tanstack/use-nav.ts +72 -14
  387. package/src/section/index.ts +1 -0
  388. package/src/section/section.module.css +104 -0
  389. package/src/section/section.module.css.d.ts +11 -0
  390. package/src/section/section.test.tsx +204 -0
  391. package/src/section/section.tsx +220 -0
  392. package/src/shell/app-breadcrumbs.tsx +44 -31
  393. package/src/shell/app-header.module.css +77 -37
  394. package/src/shell/app-header.module.css.d.ts +2 -1
  395. package/src/shell/app-mobile-nav.module.css +8 -0
  396. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  397. package/src/shell/app-mobile-nav.tsx +147 -8
  398. package/src/shell/app-sidebar-account.tsx +2 -2
  399. package/src/shell/app-sidebar.module.css +303 -20
  400. package/src/shell/app-sidebar.module.css.d.ts +19 -1
  401. package/src/shell/app-sidebar.test.tsx +456 -41
  402. package/src/shell/app-sidebar.tsx +276 -140
  403. package/src/shell/index.test.tsx +291 -5
  404. package/src/shell/index.tsx +137 -38
  405. package/src/shell/mobile-nav-model.test.ts +228 -7
  406. package/src/shell/mobile-nav-model.ts +53 -3
  407. package/src/shell/nav-count-badge.tsx +1 -1
  408. package/src/shell/page-bar.module.css +178 -0
  409. package/src/shell/page-bar.module.css.d.ts +17 -0
  410. package/src/shell/page-bar.test.tsx +316 -0
  411. package/src/shell/page-bar.tsx +315 -0
  412. package/src/shell/sidebar-block-model.ts +83 -0
  413. package/src/shell/sidebar-blocks.tsx +301 -0
  414. package/src/shell/sidebar-search.module.css +26 -0
  415. package/src/shell/sidebar-search.module.css.d.ts +2 -0
  416. package/src/shell/sidebar-search.tsx +71 -2
  417. package/src/state/fields.test.tsx +258 -0
  418. package/src/state/fields.ts +892 -0
  419. package/src/state/persisted.ts +211 -0
  420. package/src/state.ts +46 -240
  421. package/src/styles.css +13 -0
  422. package/src/surfaces.ts +203 -36
  423. package/src/theme/controls.module.css +16 -1
  424. package/src/theme/ctl-theme.test.tsx +275 -0
  425. package/src/theme/ctl-theme.tsx +138 -0
  426. package/src/theme/ctl-tier-coverage.test.ts +170 -0
  427. package/src/theme/density-relations.test.ts +106 -51
  428. package/src/theme/divider-law.test.ts +259 -0
  429. package/src/theme/icon-slot.module.css +47 -0
  430. package/src/theme/icon-slot.module.css.d.ts +8 -0
  431. package/src/theme/icon-slot.test.tsx +275 -0
  432. package/src/theme/icon-slot.tsx +59 -0
  433. package/src/theme/index.ts +165 -3
  434. package/src/theme/layout-rhythm-css.test.ts +72 -0
  435. package/src/theme/radius.test.ts +11 -1
  436. package/src/theme/segmented-control.module.css +20 -0
  437. package/src/theme/shadow-surfaces.test.ts +20 -7
  438. package/src/theme/spacing.test.ts +75 -27
  439. package/src/theme/use-basalt-spacing.ts +2 -2
  440. package/src/tokens/density.test.ts +7 -25
  441. package/src/tokens/index.ts +48 -8
  442. package/src/tokens/palette.ts +107 -73
  443. package/src/widget-header/delta-badge.test.tsx +60 -0
  444. package/src/widget-header/delta-badge.tsx +57 -0
  445. package/src/widget-header/index.ts +2 -0
  446. package/src/widget-header/no-mantine.test.ts +33 -0
  447. package/src/widget-header/widget-header.module.css +335 -0
  448. package/src/widget-header/widget-header.module.css.d.ts +24 -0
  449. package/src/widget-header/widget-header.test.tsx +187 -0
  450. package/src/widget-header/widget-header.tsx +168 -0
  451. package/agent/rules/basalt-agent.md +0 -452
  452. package/agent/rules/basalt-app.md +0 -246
  453. package/agent/rules/basalt-commands.md +0 -320
  454. package/agent/rules/basalt-content.md +0 -409
  455. package/agent/rules/basalt-data.md +0 -269
  456. package/agent/rules/basalt-forms.md +0 -186
  457. package/agent/rules/basalt-notifications.md +0 -264
  458. package/agent/rules/basalt-query.md +0 -187
  459. package/agent/rules/basalt-router.md +0 -336
  460. package/dist/content/article-filter-bar.d.ts +0 -40
  461. package/dist/content/article-filter-bar.d.ts.map +0 -1
  462. package/dist/content/article-filter-bar.js +0 -56
  463. package/dist/shell/page-header.d.ts +0 -13
  464. package/dist/shell/page-header.d.ts.map +0 -1
  465. package/dist/shell/page-header.js +0 -23
  466. package/src/content/article-filter-bar.tsx +0 -88
  467. package/src/shell/page-header.tsx +0 -35
@@ -0,0 +1,285 @@
1
+ /**
2
+ * The mobile `Filters (n)` sheet and the two row primitives every control's sheet form composes
3
+ * (`docs/CONTROLS-SPEC.md` §2.1/§3). A bottom `Drawer`: every child of the `FilterSet` renders its
4
+ * full-width sheet form, edits APPLY IMMEDIATELY (there is no Apply button — the URL is the truth
5
+ * and a filter write is already `history: 'replace'`), and one `Reset all` footer clears every
6
+ * registered filter.
7
+ *
8
+ * 44px rows are the one sheet dimension C15 pins, so they come from
9
+ * `--vx-space-sheet-row-height` — the density-tracking token, not a literal.
10
+ *
11
+ * INTERNAL, like `FilterPill`: `FilterSet` owns when the sheet opens, so a consumer mounting it by
12
+ * hand would own a second, unregistered filter home (C1).
13
+ */
14
+ import { Button, Drawer } from '@mantine/core'
15
+ import { useId } from 'react'
16
+ import type { ReactNode } from 'react'
17
+ import classes from './controls.module.css'
18
+
19
+ export type FilterSheetProps = {
20
+ readonly opened: boolean
21
+ readonly onClose: () => void
22
+ readonly onResetAll: () => void
23
+ readonly title?: string
24
+ readonly children: ReactNode
25
+ }
26
+
27
+ /**
28
+ * `Reset all` rides the HEADER, beside the title and the close ×, not a footer.
29
+ *
30
+ * A bottom drawer's footer is the one region a phone cannot promise is on screen: the sheet grows
31
+ * with its content, so with six filters open the reset button sat below the fold and the only way to
32
+ * reach it was to scroll past every row it would have undone. The header is fixed by construction.
33
+ * It also removes the sheet's last horizontal rule — a footer needs separating from the rows above
34
+ * it, a header does not (`docs/CONTROLS-SPEC.md` §2.1: hairlines between OPTION rows only).
35
+ */
36
+ export function FilterSheet({
37
+ opened,
38
+ onClose,
39
+ onResetAll,
40
+ title = 'Filters',
41
+ children,
42
+ }: FilterSheetProps): ReactNode {
43
+ return (
44
+ <Drawer
45
+ opened={opened}
46
+ onClose={onClose}
47
+ position="bottom"
48
+ radius="sm"
49
+ classNames={{ header: classes.sheetHeader, title: classes.sheetTitle }}
50
+ title={
51
+ <>
52
+ <span className={classes.sheetTitleText}>{title}</span>
53
+ <Button variant="subtle" size="ctl" onClick={onResetAll}>
54
+ Reset all
55
+ </Button>
56
+ </>
57
+ }
58
+ >
59
+ <div className={classes.sheetBody}>{children}</div>
60
+ </Drawer>
61
+ )
62
+ }
63
+
64
+ /** The selected-row mark — 16px, drawn at the row's trailing edge (`docs/CONTROLS-SPEC.md` §2.1). */
65
+ function CheckGlyph(): ReactNode {
66
+ return (
67
+ <svg
68
+ width={16}
69
+ height={16}
70
+ viewBox="0 0 24 24"
71
+ fill="none"
72
+ stroke="currentColor"
73
+ strokeWidth={2.25}
74
+ strokeLinecap="round"
75
+ strokeLinejoin="round"
76
+ aria-hidden
77
+ >
78
+ <path d="M5 12.5l5 5l9 -11" />
79
+ </svg>
80
+ )
81
+ }
82
+
83
+ /** One row of a {@link SheetOptionList}. */
84
+ export type SheetOption = {
85
+ readonly value: string
86
+ readonly label: string
87
+ readonly disabled?: boolean
88
+ }
89
+
90
+ export type SheetOptionListProps = {
91
+ /** `single` renders radio semantics, `multi` checkbox semantics. */
92
+ readonly mode: 'single' | 'multi'
93
+ /** The selected value(s) — a one-element array in `single` mode. */
94
+ readonly selected: readonly string[]
95
+ readonly options: readonly SheetOption[]
96
+ readonly onToggle: (value: string) => void
97
+ /** The `SheetField` heading this list is named by. */
98
+ readonly labelId: string
99
+ }
100
+
101
+ /**
102
+ * The sheet's ONE list form (`docs/CONTROLS-SPEC.md` §2.1): every option is a 44px full-width row,
103
+ * label left, a check at the trailing edge when selected, a 1px hairline between rows and nowhere
104
+ * else.
105
+ *
106
+ * **It replaced a `SegmentedControl` and a Mantine `Radio` list, for two different reasons.** The
107
+ * range presets rendered as a `orientation="vertical"` SegmentedControl, which in a full-width sheet
108
+ * is a stack of left-aligned labels inside a tinted track — it read as a broken control, not as a
109
+ * choice, and it carried the track's own inset so it aligned with nothing else in the sheet. The
110
+ * enum/multi filters rendered Mantine `Radio`/`Checkbox` rows, which put a 20px dot or box in the
111
+ * leading position of every row; at six filters the sheet was a column of controls rather than a
112
+ * column of options, and the leading marks pushed every label off the sheet's own text column.
113
+ *
114
+ * The semantics are a real native `<input>` inside the row's `<label>`, visually hidden — not
115
+ * `role="radio"` on a div. A hidden native input keeps the arrow-key group behaviour, the
116
+ * form-association and the announced state for free, and `<label>` wrapping it makes the whole 44px
117
+ * box the hit target (C15) without a single `onClick` on a non-interactive element.
118
+ */
119
+ export function SheetOptionList({
120
+ mode,
121
+ selected,
122
+ options,
123
+ onToggle,
124
+ labelId,
125
+ }: SheetOptionListProps): ReactNode {
126
+ // ONE name per rendered list, so a sheet holding two range filters keeps two radio groups.
127
+ const name = useId()
128
+ return (
129
+ // A real `<fieldset>`, not `role="group"` on a div — the native element carries the grouping
130
+ // semantics and gets its own box reset in the module (`.sheetList`). `aria-labelledby` points at
131
+ // the `SheetField` heading rather than a `<legend>`: the heading is already visible above the
132
+ // list, and a legend would render it a second time.
133
+ <fieldset className={classes.sheetList} aria-labelledby={labelId}>
134
+ {options.map((option) => {
135
+ const isSelected = selected.includes(option.value)
136
+ return (
137
+ <label
138
+ key={option.value}
139
+ className={classes.sheetOption}
140
+ {...(isSelected && { 'data-selected': true })}
141
+ {...(option.disabled === true && { 'data-disabled': true })}
142
+ >
143
+ {/* theme-allow raw-form-control — the input is NEVER PAINTED. It is visually hidden
144
+ (`.sheetOptionInput`) and exists only to carry the row's semantics: the checked state,
145
+ the radio-group arrow keys, the announced name. The surface the reader sees and taps
146
+ is the `<label>` around it, which the theme's tokens style directly. A Mantine
147
+ `Radio` here would paint the 20px indicator this row form exists to remove — see
148
+ `SheetOptionList`'s doc. */}
149
+ <input
150
+ className={classes.sheetOptionInput}
151
+ type={mode === 'single' ? 'radio' : 'checkbox'}
152
+ name={name}
153
+ value={option.value}
154
+ checked={isSelected}
155
+ disabled={option.disabled === true}
156
+ aria-label={option.label}
157
+ onChange={() => {
158
+ onToggle(option.value)
159
+ }}
160
+ />
161
+ <span className={classes.sheetOptionLabel}>{option.label}</span>
162
+ {isSelected && (
163
+ <span className={classes.sheetOptionCheck} aria-hidden>
164
+ <CheckGlyph />
165
+ </span>
166
+ )}
167
+ </label>
168
+ )
169
+ })}
170
+ </fieldset>
171
+ )
172
+ }
173
+
174
+ export type SheetDisclosureProps = {
175
+ readonly label: string
176
+ readonly expanded: boolean
177
+ readonly onToggle: () => void
178
+ readonly children: ReactNode
179
+ }
180
+
181
+ /**
182
+ * A 44px row that expands its own body inline — the sheet's answer to a control too large to sit in
183
+ * the list unconditionally (`RangeFilter`'s custom date picker, which is a whole calendar). Same row
184
+ * geometry as a {@link SheetOptionList} option, so the disclosure reads as the list's last entry
185
+ * rather than as a different kind of thing.
186
+ */
187
+ export function SheetDisclosure({
188
+ label,
189
+ expanded,
190
+ onToggle,
191
+ children,
192
+ }: SheetDisclosureProps): ReactNode {
193
+ return (
194
+ <div className={classes.sheetList}>
195
+ <button
196
+ type="button"
197
+ className={classes.sheetOption}
198
+ aria-expanded={expanded}
199
+ {...(expanded && { 'data-selected': true })}
200
+ onClick={onToggle}
201
+ >
202
+ <span className={classes.sheetOptionLabel}>{label}</span>
203
+ {expanded && (
204
+ <span className={classes.sheetOptionCheck} aria-hidden>
205
+ <CheckGlyph />
206
+ </span>
207
+ )}
208
+ </button>
209
+ {expanded && <div className={classes.sheetDisclosureBody}>{children}</div>}
210
+ </div>
211
+ )
212
+ }
213
+
214
+ export type SheetFieldProps = {
215
+ readonly label: string
216
+ /** Stamped on the heading so a control can point `aria-labelledby` at it — see `useControlName`. */
217
+ readonly labelId?: string
218
+ readonly children: ReactNode
219
+ }
220
+
221
+ /** A labelled block inside the sheet — the sheet form's outer shell for every control. */
222
+ export function SheetField({ label, labelId, children }: SheetFieldProps): ReactNode {
223
+ return (
224
+ <div className={classes.sheetField}>
225
+ <span className={classes.sheetLabel} {...(labelId !== undefined && { id: labelId })}>
226
+ {label}
227
+ </span>
228
+ {children}
229
+ </div>
230
+ )
231
+ }
232
+
233
+ /**
234
+ * Names a control that has no VISIBLE Mantine `label`, and does it differently per surface — which
235
+ * is the whole reason this is one helper instead of an `aria-label` sprinkled per call site.
236
+ *
237
+ * In the SHEET the visible `SheetField` heading is right there, so the control points at it
238
+ * (`aria-labelledby`) and the heading stops being decoration. In the PILL surface there is no
239
+ * visible name to point at — the pill text is the VALUE (`EUR`, `30d`, `3 channels`) — so the
240
+ * control carries the filter's own name instead. Either way the announced name is what is being
241
+ * filtered, not what it currently holds.
242
+ *
243
+ * The id is generated by the CONTROL, not by `SheetField`, because the control renders its own
244
+ * `SheetField`: an id published downward through context could never reach the sibling that needs it.
245
+ *
246
+ * @example
247
+ * const { labelId, nameProps } = useControlName(label, inSheet)
248
+ * <Radio.Group {...nameProps} …/>
249
+ * // and, in the sheet branch: <SheetField label={label} labelId={labelId}>…</SheetField>
250
+ */
251
+ export function useControlName(
252
+ label: string,
253
+ inSheet: boolean,
254
+ ): {
255
+ labelId: string
256
+ nameProps: { 'aria-labelledby': string } | { 'aria-label': string }
257
+ } {
258
+ const labelId = useId()
259
+ return {
260
+ labelId,
261
+ nameProps: inSheet ? { 'aria-labelledby': labelId } : { 'aria-label': label },
262
+ }
263
+ }
264
+
265
+ export type SheetRowProps = {
266
+ readonly children: ReactNode
267
+ }
268
+
269
+ /** One 44px full-width row (`--vx-space-sheet-row-height`). */
270
+ export function SheetRow({ children }: SheetRowProps): ReactNode {
271
+ return <div className={classes.sheetRow}>{children}</div>
272
+ }
273
+
274
+ /** The class every sheet-form list item shares, for controls that render their own row element. */
275
+ export const sheetRowClass = classes.sheetRow
276
+
277
+ /**
278
+ * `classNames` for a Radio/Checkbox/Switch rendered as a sheet row — stretches its `body` and
279
+ * `<label>` across the full 44px so the whole row is the touch target, not the middle 20px of it.
280
+ */
281
+ export const sheetRowClassNames = {
282
+ root: classes.sheetRow,
283
+ body: classes.sheetRowBody,
284
+ label: classes.sheetRowLabel,
285
+ } as const
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The control tier's own inline glyphs (`docs/CONTROLS-SPEC.md` §3).
3
+ *
4
+ * basalt ships NO icon dependency — every `icon` slot in the public API takes a `ReactNode` the
5
+ * consumer fills. These are the few glyphs the FRAMEWORK draws for itself, where a default icon is
6
+ * part of the control's identity rather than a caller's decision: a range filter reads as a date
7
+ * picker because it carries a calendar, a compare filter because it carries the swap arrows, and
8
+ * the mobile `Filters (n)` pill because it carries a funnel. Same convention the shell already uses
9
+ * for its own chrome (`shell/sidebar-search.tsx`, `shell/app-sidebar.tsx`): a 24×24 `viewBox`,
10
+ * `stroke="currentColor"`, sized down by the `size` prop.
11
+ *
12
+ * INTERNAL — not on the `./controls` barrel. A consumer wanting a different glyph passes `icon`.
13
+ */
14
+ import type { ReactNode } from 'react'
15
+
16
+ /** The tier's icon box: 16px inside a button, 14px for a trailing affordance. */
17
+ const ICON_SIZE = 16
18
+ const AFFORDANCE_SIZE = 14
19
+
20
+ function Stroke({ size = ICON_SIZE, children }: { size?: number; children: ReactNode }): ReactNode {
21
+ return (
22
+ <svg
23
+ width={size}
24
+ height={size}
25
+ viewBox="0 0 24 24"
26
+ fill="none"
27
+ stroke="currentColor"
28
+ strokeWidth={1.75}
29
+ strokeLinecap="round"
30
+ strokeLinejoin="round"
31
+ aria-hidden
32
+ >
33
+ {children}
34
+ </svg>
35
+ )
36
+ }
37
+
38
+ /** `RangeFilter`'s default leading icon. */
39
+ export function CalendarGlyph(): ReactNode {
40
+ return (
41
+ <Stroke>
42
+ <rect x={3} y={5} width={18} height={16} rx={2.5} />
43
+ <path d="M3 10h18" />
44
+ <path d="M8 3v4" />
45
+ <path d="M16 3v4" />
46
+ </Stroke>
47
+ )
48
+ }
49
+
50
+ /** `CompareFilter`'s default leading icon — the arrows-swap glyph. */
51
+ export function SwapGlyph(): ReactNode {
52
+ return (
53
+ <Stroke>
54
+ <path d="M4 8h13l-3.5 -3.5" />
55
+ <path d="M20 16h-13l3.5 3.5" />
56
+ </Stroke>
57
+ )
58
+ }
59
+
60
+ /** The mobile `Filters (n)` pill's leading icon. */
61
+ export function FunnelGlyph(): ReactNode {
62
+ return (
63
+ <Stroke>
64
+ <path d="M4 4h16l-6.5 8v7l-3 -1.5v-5.5z" />
65
+ </Stroke>
66
+ )
67
+ }
68
+
69
+ /**
70
+ * Every pill's trailing affordance — 14px, muted, and an SVG rather than the `⇅` character it
71
+ * replaced. A text glyph rendered at the label's own font metrics, so it sat off the optical centre
72
+ * and shifted with the font stack; the path is centred in its own box.
73
+ */
74
+ export function UpDownGlyph(): ReactNode {
75
+ return (
76
+ <Stroke size={AFFORDANCE_SIZE}>
77
+ <path d="M8 9l4 -4l4 4" />
78
+ <path d="M8 15l4 4l4 -4" />
79
+ </Stroke>
80
+ )
81
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `basalt-ui/controls` — the Mantine-coupled control tier (`docs/CONTROLS-SPEC.md` §3).
3
+ *
4
+ * Every control here renders at the `ctl` tier from the home that hosts it and owns its own
5
+ * desktop/mobile swap in CSS (law C9). Actions and sync land in wave 3; the filter/tab family
6
+ * follows in wave 4.
7
+ */
8
+ export {
9
+ ActionGroup,
10
+ OverflowMenu,
11
+ DESKTOP_SECONDARY_MAX,
12
+ MOBILE_GLOBAL_BAR_MAX,
13
+ barActionMobile,
14
+ globalActionMobile,
15
+ type BarAction,
16
+ type BarActionItem,
17
+ type BarActionMenu,
18
+ type BarActionCustom,
19
+ type ActionGroupProps,
20
+ type GlobalAction,
21
+ type OverflowMenuProps,
22
+ } from './actions'
23
+ export { SyncButton, formatAge, AGE_REFRESH_MS, type SyncButtonProps } from './sync-button'
24
+ export { ControlGroup, type ControlGroupProps } from './control-group'
25
+
26
+ // ── Filters + tabs (wave 4) ───────────────────────────────────────────────────────────────────
27
+ // `FilterPill`, `FilterSheet` and the `EnumFilter` body are deliberately NOT here: reaching for
28
+ // them is hand-rolling a filter (`basalt/hand-rolled-filter`), and `useFilterSurface` is only
29
+ // meaningful to a control living inside a `FilterSet` — i.e. to this folder.
30
+ export { FilterSet, type FilterSetProps } from './filter-set'
31
+ export { RangeFilter, type RangeCustomPickerProps, type RangeFilterProps } from './range-filter'
32
+ export {
33
+ CompareFilter,
34
+ COMPARE_VALUES,
35
+ type CompareFilterProps,
36
+ type CompareValue,
37
+ } from './compare-filter'
38
+ export {
39
+ SelectFilter,
40
+ type FilterOption,
41
+ type SelectFilterEnumProps,
42
+ type SelectFilterProps,
43
+ type SelectFilterStringProps,
44
+ } from './select-filter'
45
+ export { MultiSelectFilter, type MultiSelectFilterProps } from './multi-select-filter'
46
+ export { SearchFilter, type SearchFilterProps } from './search-filter'
47
+ export { ToggleFilter, type ToggleFilterProps } from './toggle-filter'
48
+ export { ViewTabs, type ViewTabsOption, type ViewTabsProps } from './view-tabs'
@@ -0,0 +1,140 @@
1
+ /**
2
+ * `MultiSelectFilter` — an any-of set over one closed enum (`docs/CONTROLS-SPEC.md` §3).
3
+ *
4
+ * The pill reads the filter's name while the selection carries no information — empty (no
5
+ * constraint) or every option (the same thing said the long way) — and switches to a count once it
6
+ * does: `All channels` → `3 channels`. `noun` is the plural read in that count and defaults to the
7
+ * label lowercased, so `label="Channels"` needs no second prop.
8
+ *
9
+ * @example
10
+ * <MultiSelectFilter field={analytics.field.channels} label="All channels" noun="channels" />
11
+ *
12
+ * @example
13
+ * // A runtime catalogue over the same closed field — live labels, same values.
14
+ * <MultiSelectFilter
15
+ * field={analytics.field.channels}
16
+ * label="All channels"
17
+ * options={CHANNELS.map((c) => ({ value: c, label: `${c} · ${counts[c]}` }))}
18
+ * />
19
+ */
20
+ import { Button, Checkbox, Stack } from '@mantine/core'
21
+ import type { ReactNode } from 'react'
22
+ import type { FieldHandle, MultiField } from '../state'
23
+ import type { FilterOption } from './select-filter'
24
+ import classes from './controls.module.css'
25
+ import { useFilterRegistration, useFilterSurface } from './filter-context'
26
+ import { FilterPill } from './filter-pill'
27
+ import { SheetField, SheetOptionList, useControlName } from './filter-sheet'
28
+
29
+ export type MultiSelectFilterProps<T extends string> = {
30
+ readonly field: FieldHandle<MultiField<T>>
31
+ readonly label: string
32
+ readonly icon?: ReactNode
33
+ /**
34
+ * The plural noun in the `3 channels` count.
35
+ *
36
+ * @default the label, lowercased
37
+ */
38
+ readonly noun?: string
39
+ /**
40
+ * Overrides `field.options` at render — a runtime catalogue whose labels carry live data
41
+ * (`web · 1.2k`). The VALUES still belong to the field: a multi field is a closed set, so this
42
+ * relabels and reorders rows, it does not open new ones. Unlike `SelectFilter` there is no
43
+ * string-field shape to make it required.
44
+ */
45
+ readonly options?: readonly FilterOption[]
46
+ }
47
+
48
+ export function MultiSelectFilter<T extends string>({
49
+ field,
50
+ label,
51
+ icon,
52
+ noun,
53
+ options: optionsProp,
54
+ }: MultiSelectFilterProps<T>): ReactNode {
55
+ const [value, setValue] = field.use()
56
+ const surface = useFilterSurface()
57
+ const isDefault = field.isDefault(value)
58
+ useFilterRegistration(!isDefault, () => {
59
+ setValue(field.fallback)
60
+ })
61
+
62
+ // The prop wins whole, never merged — same rule as `EnumFilter`: a catalogue that dropped a row
63
+ // must be able to drop it, and `All channels` is counted against the rows actually shown.
64
+ const options: readonly FilterOption[] = optionsProp ?? field.options
65
+ const inSheet = surface === 'sheet'
66
+ const carriesInformation = value.length > 0 && value.length < options.length
67
+ const { labelId } = useControlName(label, inSheet)
68
+
69
+ // The SHEET form is a `SheetOptionList` in `multi` mode — 44px rows, a trailing check on each
70
+ // selected one, hairlines between rows only. Same reasoning as `EnumFilter`: the popover keeps
71
+ // Mantine's `Checkbox.Group`, the sheet reads as a list of options rather than a stack of boxes.
72
+ if (inSheet) {
73
+ return (
74
+ <SheetField label={label} labelId={labelId}>
75
+ <SheetOptionList
76
+ mode="multi"
77
+ labelId={labelId}
78
+ selected={value}
79
+ options={options}
80
+ onToggle={(next) => {
81
+ // The rows are rendered from `options`, whose values belong to the field either way (the
82
+ // prop relabels a closed set, it cannot open it) — the cast restores what the codec
83
+ // already guarantees.
84
+ const has = value.includes(next as T)
85
+ setValue(has ? value.filter((v) => v !== next) : [...value, next as T])
86
+ }}
87
+ />
88
+ </SheetField>
89
+ )
90
+ }
91
+
92
+ const body = (
93
+ // Mantine's own `label`, for the same reason `enum-filter.tsx` uses it — `Checkbox.Group`
94
+ // overwrites any `aria-label`/`aria-labelledby` with its own. Named from `label`, never from the
95
+ // pill text, which reads `3 channels` and describes the selection rather than the filter.
96
+ <Checkbox.Group
97
+ label={label}
98
+ classNames={{ label: classes.groupLabel }}
99
+ value={[...value]}
100
+ onChange={(next) => {
101
+ // The boxes are rendered from `options`, whose values belong to the field either way (the
102
+ // prop relabels a closed set, it cannot open it) — the cast restores what the codec
103
+ // already guarantees.
104
+ setValue(next as readonly T[])
105
+ }}
106
+ >
107
+ <Stack gap={2}>
108
+ {options.map((option) => (
109
+ <Checkbox
110
+ key={option.value}
111
+ value={option.value}
112
+ label={option.label}
113
+ {...(option.disabled === true && { disabled: true })}
114
+ />
115
+ ))}
116
+ </Stack>
117
+ {!isDefault && (
118
+ <Button
119
+ variant="subtle"
120
+ size="ctl"
121
+ onClick={() => {
122
+ setValue(field.fallback)
123
+ }}
124
+ >
125
+ Clear
126
+ </Button>
127
+ )}
128
+ </Checkbox.Group>
129
+ )
130
+
131
+ return (
132
+ <FilterPill
133
+ label={carriesInformation ? `${value.length} ${noun ?? label.toLowerCase()}` : label}
134
+ active={!isDefault}
135
+ {...(icon !== undefined && { icon })}
136
+ >
137
+ <div className={classes.optionList}>{body}</div>
138
+ </FilterPill>
139
+ )
140
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The packaging invariant `./controls` and `./controls-dates` exist to express
3
+ * (`docs/CONTROLS-SPEC.md` §3): `@mantine/dates` is an OPTIONAL peer, so `basalt-ui/controls` must
4
+ * resolve for a consumer who has never installed it.
5
+ *
6
+ * Source-level, and deliberately so: `scripts/pack-test.sh` proves the same thing from the PACKED
7
+ * tarball with the peer genuinely absent, which is the stronger claim — but it runs in CI, on a
8
+ * built artifact, minutes later. This file fails in the unit loop, on the line that broke it, which
9
+ * is what makes the invariant cheap enough to hold. Both, not either.
10
+ *
11
+ * The second half is the direction of the dependency: `./controls-dates` may reach INTO `./controls`
12
+ * (for the `RangeCustomPickerProps` contract, type-only), never the reverse — a runtime import back
13
+ * would put `@mantine/dates` in `./controls`' graph through the side door, which is exactly the
14
+ * failure a lazy `import()` would also cause and the reason `RangeFilter` takes the picker as a prop.
15
+ */
16
+ import { describe, expect, test } from 'bun:test'
17
+ import { readFileSync } from 'node:fs'
18
+ import { join } from 'node:path'
19
+
20
+ const SRC = join(import.meta.dir, '..')
21
+
22
+ const BLOCK_COMMENT = /\/\*[\s\S]*?\*\//g
23
+ const LINE_COMMENT = /\/\/.*$/gm
24
+
25
+ /** Every static or dynamic reference to `spec` that survives to runtime. Type-only forms excluded. */
26
+ function runtimeReferences(source: string, spec: string): boolean {
27
+ const code = source.replace(BLOCK_COMMENT, '').replace(LINE_COMMENT, '')
28
+ const quoted = `['"]${spec.replace(/[/*]/g, (c) => `\\${c}`)}['"]`
29
+ const staticForm = new RegExp(
30
+ `^(?:import|export)\\s+(?!type\\b)[^\\n]*from\\s+${quoted}|^import\\s+(?!type\\b)${quoted}`,
31
+ 'm',
32
+ )
33
+ const dynamicForm = new RegExp(`import\\s*\\(\\s*${quoted}`)
34
+ return staticForm.test(code) || dynamicForm.test(code)
35
+ }
36
+
37
+ function filesUnder(dir: string): string[] {
38
+ const glob = new Bun.Glob('**/*.{ts,tsx}')
39
+ return [...glob.scanSync({ cwd: join(SRC, dir) })].map((rel) => join(SRC, dir, rel))
40
+ }
41
+
42
+ describe('./controls never reaches @mantine/dates', () => {
43
+ const files = filesUnder('controls')
44
+
45
+ test('the walk found files to scan', () => {
46
+ expect(files.length).toBeGreaterThan(5)
47
+ })
48
+
49
+ test('no file under src/controls imports @mantine/dates, statically or lazily', () => {
50
+ const violations = files
51
+ .filter((file) => runtimeReferences(readFileSync(file, 'utf8'), '@mantine/dates'))
52
+ .map((file) => file.slice(SRC.length + 1))
53
+ expect(violations).toEqual([])
54
+ })
55
+ })
56
+
57
+ describe('the dependency points one way', () => {
58
+ test('no file under src/controls imports src/controls-dates', () => {
59
+ const violations = filesUnder('controls')
60
+ .filter((file) => /from\s+['"]\.\.\/controls-dates/.test(readFileSync(file, 'utf8')))
61
+ .map((file) => file.slice(SRC.length + 1))
62
+ expect(violations).toEqual([])
63
+ })
64
+
65
+ test('src/controls-dates reaches ../controls for TYPES only — a runtime edge would leak the peer', () => {
66
+ const violations = filesUnder('controls-dates')
67
+ .filter((file) => {
68
+ const source = readFileSync(file, 'utf8')
69
+ return /from\s+['"]\.\.\/controls/.test(
70
+ source
71
+ .replace(/export\s+type\s+\{[^}]*\}\s+from\s+['"][^'"]*['"]/g, '')
72
+ .replace(/import\s+type\s+\{[^}]*\}\s+from\s+['"][^'"]*['"]/g, ''),
73
+ )
74
+ })
75
+ .map((file) => file.slice(SRC.length + 1))
76
+ expect(violations).toEqual([])
77
+ })
78
+
79
+ test('@mantine/dates is reached from exactly one place — src/controls-dates', () => {
80
+ const importers = filesUnder('controls-dates')
81
+ .filter((file) => runtimeReferences(readFileSync(file, 'utf8'), '@mantine/dates'))
82
+ .map((file) => file.slice(SRC.length + 1))
83
+ expect(importers).toEqual(['controls-dates/date-range-picker.tsx'])
84
+ })
85
+ })