basalt-ui 1.25.0 → 1.27.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 (476) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +26 -0
  3. package/MIGRATING.md +815 -23
  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 +160 -0
  8. package/agent/rules/basalt-mantine.md +146 -439
  9. package/agent/rules/basalt-state.md +118 -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 +1465 -36
  17. package/configs/oxlint-plugin.test.ts +1190 -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 +39 -0
  73. package/dist/controls/enum-filter.d.ts.map +1 -0
  74. package/dist/controls/enum-filter.js +93 -0
  75. package/dist/controls/filter-context.d.ts +60 -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 +20 -0
  93. package/dist/controls/index.d.ts.map +1 -0
  94. package/dist/controls/index.js +46 -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/number-filter.d.ts +26 -0
  99. package/dist/controls/number-filter.d.ts.map +1 -0
  100. package/dist/controls/number-filter.js +143 -0
  101. package/dist/controls/peer-boundary.test.d.ts +2 -0
  102. package/dist/controls/peer-boundary.test.d.ts.map +1 -0
  103. package/dist/controls/props-contract.test.d.ts +2 -0
  104. package/dist/controls/props-contract.test.d.ts.map +1 -0
  105. package/dist/controls/range-filter.d.ts +43 -0
  106. package/dist/controls/range-filter.d.ts.map +1 -0
  107. package/dist/controls/range-filter.js +135 -0
  108. package/dist/controls/range-filter.type-guard.test.d.ts +2 -0
  109. package/dist/controls/range-filter.type-guard.test.d.ts.map +1 -0
  110. package/dist/controls/search-filter.d.ts +10 -0
  111. package/dist/controls/search-filter.d.ts.map +1 -0
  112. package/dist/controls/search-filter.js +81 -0
  113. package/dist/controls/select-filter.d.ts +74 -0
  114. package/dist/controls/select-filter.d.ts.map +1 -0
  115. package/dist/controls/select-filter.js +10 -0
  116. package/dist/controls/sync-button.d.ts +38 -0
  117. package/dist/controls/sync-button.d.ts.map +1 -0
  118. package/dist/controls/sync-button.js +119 -0
  119. package/dist/controls/sync-button.module.css +63 -0
  120. package/dist/controls/sync-button.module.css.d.ts +11 -0
  121. package/dist/controls/sync-button.test.d.ts +2 -0
  122. package/dist/controls/sync-button.test.d.ts.map +1 -0
  123. package/dist/controls/toggle-filter.d.ts +9 -0
  124. package/dist/controls/toggle-filter.d.ts.map +1 -0
  125. package/dist/controls/toggle-filter.js +42 -0
  126. package/dist/controls/view-tabs.d.ts +23 -0
  127. package/dist/controls/view-tabs.d.ts.map +1 -0
  128. package/dist/controls/view-tabs.js +59 -0
  129. package/dist/controls-dates/date-range-picker.d.ts +4 -0
  130. package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
  131. package/dist/controls-dates/date-range-picker.js +18 -0
  132. package/dist/controls-dates/index.d.ts +9 -0
  133. package/dist/controls-dates/index.d.ts.map +1 -0
  134. package/dist/controls-dates/index.js +4 -0
  135. package/dist/dashboard/delta-badge.d.ts +6 -12
  136. package/dist/dashboard/delta-badge.d.ts.map +1 -1
  137. package/dist/dashboard/delta-badge.js +1 -50
  138. package/dist/dashboard/index.d.ts +1 -1
  139. package/dist/dashboard/index.d.ts.map +1 -1
  140. package/dist/dashboard/index.js +3 -1
  141. package/dist/dashboard/settings-section.d.ts +6 -4
  142. package/dist/dashboard/settings-section.d.ts.map +1 -1
  143. package/dist/dashboard/settings-section.js +23 -16
  144. package/dist/dashboard/settings-section.module.css +10 -0
  145. package/dist/dashboard/settings-section.module.css.d.ts +1 -0
  146. package/dist/dashboard/settings-section.test.d.ts +2 -0
  147. package/dist/dashboard/settings-section.test.d.ts.map +1 -0
  148. package/dist/dashboard/stat-card.d.ts +95 -9
  149. package/dist/dashboard/stat-card.d.ts.map +1 -1
  150. package/dist/dashboard/stat-card.js +108 -49
  151. package/dist/dashboard/stat-card.module.css +153 -0
  152. package/dist/dashboard/stat-card.module.css.d.ts +15 -0
  153. package/dist/data/data-table.d.ts +37 -15
  154. package/dist/data/data-table.d.ts.map +1 -1
  155. package/dist/data/data-table.js +96 -58
  156. package/dist/guard/grace.test.d.ts +20 -0
  157. package/dist/guard/grace.test.d.ts.map +1 -0
  158. package/dist/guard/index.d.ts +66 -3
  159. package/dist/guard/index.d.ts.map +1 -1
  160. package/dist/guard/index.js +78 -7
  161. package/dist/guard/types.d.ts +3 -3
  162. package/dist/guard/types.d.ts.map +1 -1
  163. package/dist/index.d.ts +13 -3
  164. package/dist/index.d.ts.map +1 -1
  165. package/dist/index.js +13 -10
  166. package/dist/nav/types.d.ts +78 -2
  167. package/dist/nav/types.d.ts.map +1 -1
  168. package/dist/notifications/bell.d.ts +4 -1
  169. package/dist/notifications/bell.d.ts.map +1 -1
  170. package/dist/notifications/index.d.ts +1 -1
  171. package/dist/router-tanstack/field.d.ts +14 -0
  172. package/dist/router-tanstack/field.d.ts.map +1 -0
  173. package/dist/router-tanstack/field.js +4 -0
  174. package/dist/router-tanstack/index.d.ts +6 -2
  175. package/dist/router-tanstack/index.d.ts.map +1 -1
  176. package/dist/router-tanstack/index.js +4 -0
  177. package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
  178. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  179. package/dist/router-tanstack/multi-search-param-store.js +19 -27
  180. package/dist/router-tanstack/search-param-store.d.ts +3 -87
  181. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  182. package/dist/router-tanstack/search-param-store.js +16 -45
  183. package/dist/router-tanstack/search-store.d.ts +84 -0
  184. package/dist/router-tanstack/search-store.d.ts.map +1 -0
  185. package/dist/router-tanstack/search-store.js +271 -0
  186. package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
  187. package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
  188. package/dist/router-tanstack/search-store.test.d.ts +2 -0
  189. package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
  190. package/dist/router-tanstack/use-nav.d.ts +0 -2
  191. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  192. package/dist/router-tanstack/use-nav.js +34 -12
  193. package/dist/router-tanstack/use-nav.test.d.ts +2 -0
  194. package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
  195. package/dist/section/index.d.ts +2 -0
  196. package/dist/section/index.d.ts.map +1 -0
  197. package/dist/section/index.js +4 -0
  198. package/dist/section/section.d.ts +24 -0
  199. package/dist/section/section.d.ts.map +1 -0
  200. package/dist/section/section.js +126 -0
  201. package/dist/section/section.module.css +104 -0
  202. package/dist/section/section.module.css.d.ts +11 -0
  203. package/dist/section/section.test.d.ts +2 -0
  204. package/dist/section/section.test.d.ts.map +1 -0
  205. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  206. package/dist/shell/app-breadcrumbs.js +18 -15
  207. package/dist/shell/app-header.module.css +77 -37
  208. package/dist/shell/app-header.module.css.d.ts +2 -1
  209. package/dist/shell/app-mobile-nav.d.ts +9 -2
  210. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  211. package/dist/shell/app-mobile-nav.js +98 -11
  212. package/dist/shell/app-mobile-nav.module.css +8 -0
  213. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  214. package/dist/shell/app-sidebar.d.ts +55 -28
  215. package/dist/shell/app-sidebar.d.ts.map +1 -1
  216. package/dist/shell/app-sidebar.js +116 -67
  217. package/dist/shell/app-sidebar.module.css +303 -20
  218. package/dist/shell/app-sidebar.module.css.d.ts +19 -1
  219. package/dist/shell/index.d.ts +55 -21
  220. package/dist/shell/index.d.ts.map +1 -1
  221. package/dist/shell/index.js +40 -18
  222. package/dist/shell/mobile-nav-model.d.ts +13 -2
  223. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  224. package/dist/shell/mobile-nav-model.js +15 -1
  225. package/dist/shell/nav-count-badge.d.ts +1 -1
  226. package/dist/shell/page-bar.d.ts +51 -0
  227. package/dist/shell/page-bar.d.ts.map +1 -0
  228. package/dist/shell/page-bar.js +154 -0
  229. package/dist/shell/page-bar.module.css +178 -0
  230. package/dist/shell/page-bar.module.css.d.ts +17 -0
  231. package/dist/shell/page-bar.test.d.ts +2 -0
  232. package/dist/shell/page-bar.test.d.ts.map +1 -0
  233. package/dist/shell/sidebar-block-model.d.ts +48 -0
  234. package/dist/shell/sidebar-block-model.d.ts.map +1 -0
  235. package/dist/shell/sidebar-block-model.js +39 -0
  236. package/dist/shell/sidebar-blocks.d.ts +32 -0
  237. package/dist/shell/sidebar-blocks.d.ts.map +1 -0
  238. package/dist/shell/sidebar-blocks.js +169 -0
  239. package/dist/shell/sidebar-search.d.ts +13 -1
  240. package/dist/shell/sidebar-search.d.ts.map +1 -1
  241. package/dist/shell/sidebar-search.js +34 -1
  242. package/dist/shell/sidebar-search.module.css +26 -0
  243. package/dist/shell/sidebar-search.module.css.d.ts +2 -0
  244. package/dist/state/fields.d.ts +565 -0
  245. package/dist/state/fields.d.ts.map +1 -0
  246. package/dist/state/fields.js +405 -0
  247. package/dist/state/fields.test.d.ts +2 -0
  248. package/dist/state/fields.test.d.ts.map +1 -0
  249. package/dist/state/persisted.d.ts +60 -0
  250. package/dist/state/persisted.d.ts.map +1 -0
  251. package/dist/state/persisted.js +123 -0
  252. package/dist/state/persisted.test.d.ts +2 -0
  253. package/dist/state/persisted.test.d.ts.map +1 -0
  254. package/dist/state.d.ts +11 -56
  255. package/dist/state.d.ts.map +1 -1
  256. package/dist/state.js +14 -120
  257. package/dist/styles.css +13 -0
  258. package/dist/surfaces.d.ts +106 -24
  259. package/dist/surfaces.d.ts.map +1 -1
  260. package/dist/surfaces.js +156 -19
  261. package/dist/theme/controls.module.css +16 -1
  262. package/dist/theme/ctl-theme.d.ts +32 -0
  263. package/dist/theme/ctl-theme.d.ts.map +1 -0
  264. package/dist/theme/ctl-theme.js +54 -0
  265. package/dist/theme/ctl-theme.test.d.ts +2 -0
  266. package/dist/theme/ctl-theme.test.d.ts.map +1 -0
  267. package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
  268. package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
  269. package/dist/theme/divider-law.test.d.ts +2 -0
  270. package/dist/theme/divider-law.test.d.ts.map +1 -0
  271. package/dist/theme/icon-slot.d.ts +44 -0
  272. package/dist/theme/icon-slot.d.ts.map +1 -0
  273. package/dist/theme/icon-slot.js +21 -0
  274. package/dist/theme/icon-slot.module.css +47 -0
  275. package/dist/theme/icon-slot.module.css.d.ts +8 -0
  276. package/dist/theme/icon-slot.test.d.ts +2 -0
  277. package/dist/theme/icon-slot.test.d.ts.map +1 -0
  278. package/dist/theme/index.d.ts +1 -0
  279. package/dist/theme/index.d.ts.map +1 -1
  280. package/dist/theme/index.js +82 -5
  281. package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
  282. package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
  283. package/dist/theme/segmented-control.module.css +20 -0
  284. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  285. package/dist/tokens/index.d.ts +12 -0
  286. package/dist/tokens/index.d.ts.map +1 -1
  287. package/dist/tokens/index.js +38 -8
  288. package/dist/tokens/palette.d.ts +97 -61
  289. package/dist/tokens/palette.d.ts.map +1 -1
  290. package/dist/tokens/palette.js +76 -38
  291. package/dist/tokens.css +10 -2
  292. package/dist/widget-header/delta-badge.d.ts +13 -0
  293. package/dist/widget-header/delta-badge.d.ts.map +1 -0
  294. package/dist/widget-header/delta-badge.js +29 -0
  295. package/dist/widget-header/delta-badge.test.d.ts +2 -0
  296. package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
  297. package/dist/widget-header/index.d.ts +3 -0
  298. package/dist/widget-header/index.d.ts.map +1 -0
  299. package/dist/widget-header/index.js +6 -0
  300. package/dist/widget-header/no-mantine.test.d.ts +2 -0
  301. package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
  302. package/dist/widget-header/widget-header.d.ts +67 -0
  303. package/dist/widget-header/widget-header.d.ts.map +1 -0
  304. package/dist/widget-header/widget-header.js +89 -0
  305. package/dist/widget-header/widget-header.module.css +350 -0
  306. package/dist/widget-header/widget-header.module.css.d.ts +25 -0
  307. package/dist/widget-header/widget-header.test.d.ts +2 -0
  308. package/dist/widget-header/widget-header.test.d.ts.map +1 -0
  309. package/llms.txt +30 -17
  310. package/package.json +14 -1
  311. package/src/charts/cursor/resolve.test.ts +29 -1
  312. package/src/charts/cursor/resolve.ts +38 -6
  313. package/src/charts/cursor/scope.tsx +6 -3
  314. package/src/charts/cursor/store.test.ts +19 -1
  315. package/src/charts/cursor/store.ts +10 -5
  316. package/src/charts/hooks/useChartCursor.ts +16 -8
  317. package/src/charts/index.ts +2 -2
  318. package/src/charts/primitives/CartesianChart.test.tsx +124 -0
  319. package/src/charts/primitives/ChartCard.test.tsx +66 -0
  320. package/src/charts/primitives/ChartCard.tsx +86 -146
  321. package/src/charts/sparklines/BarSparkline.tsx +43 -5
  322. package/src/cli/check-coverage.test.ts +297 -0
  323. package/src/cli/check-theme.test.ts +38 -14
  324. package/src/cli/index.ts +392 -16
  325. package/src/cli/init-sync.test.ts +97 -0
  326. package/src/connectivity/connectivity-indicator.tsx +16 -9
  327. package/src/content/index.ts +16 -3
  328. package/src/content/prose.module.css +7 -28
  329. package/src/content/prose.module.css.test.ts +8 -24
  330. package/src/controls/actions.module.css +62 -0
  331. package/src/controls/actions.module.css.d.ts +13 -0
  332. package/src/controls/actions.test.tsx +252 -0
  333. package/src/controls/actions.tsx +655 -0
  334. package/src/controls/actions.type-guard.test.ts +63 -0
  335. package/src/controls/compare-filter.tsx +64 -0
  336. package/src/controls/control-group.test.tsx +229 -0
  337. package/src/controls/control-group.tsx +63 -0
  338. package/src/controls/controls.module.css +542 -0
  339. package/src/controls/controls.module.css.d.ts +35 -0
  340. package/src/controls/controls.router.test.tsx +1053 -0
  341. package/src/controls/enum-filter.tsx +170 -0
  342. package/src/controls/filter-context.tsx +158 -0
  343. package/src/controls/filter-pill.tsx +120 -0
  344. package/src/controls/filter-set.test.tsx +472 -0
  345. package/src/controls/filter-set.tsx +225 -0
  346. package/src/controls/filter-sheet.tsx +285 -0
  347. package/src/controls/glyphs.tsx +81 -0
  348. package/src/controls/index.ts +49 -0
  349. package/src/controls/multi-select-filter.tsx +140 -0
  350. package/src/controls/number-filter.tsx +291 -0
  351. package/src/controls/peer-boundary.test.ts +85 -0
  352. package/src/controls/props-contract.test.tsx +137 -0
  353. package/src/controls/range-filter.tsx +237 -0
  354. package/src/controls/range-filter.type-guard.test.tsx +122 -0
  355. package/src/controls/search-filter.tsx +123 -0
  356. package/src/controls/select-filter.tsx +89 -0
  357. package/src/controls/sync-button.module.css +63 -0
  358. package/src/controls/sync-button.module.css.d.ts +11 -0
  359. package/src/controls/sync-button.test.tsx +183 -0
  360. package/src/controls/sync-button.tsx +197 -0
  361. package/src/controls/toggle-filter.tsx +60 -0
  362. package/src/controls/view-tabs.tsx +110 -0
  363. package/src/controls-dates/date-range-picker.tsx +42 -0
  364. package/src/controls-dates/index.ts +8 -0
  365. package/src/dashboard/delta-badge.tsx +4 -74
  366. package/src/dashboard/index.ts +6 -1
  367. package/src/dashboard/settings-section.module.css +10 -0
  368. package/src/dashboard/settings-section.module.css.d.ts +1 -0
  369. package/src/dashboard/settings-section.test.tsx +56 -0
  370. package/src/dashboard/settings-section.tsx +33 -30
  371. package/src/dashboard/stat-card.module.css +153 -0
  372. package/src/dashboard/stat-card.module.css.d.ts +15 -0
  373. package/src/dashboard/stat-card.test.tsx +298 -1
  374. package/src/dashboard/stat-card.tsx +289 -67
  375. package/src/data/data-table.test.tsx +96 -11
  376. package/src/data/data-table.tsx +228 -84
  377. package/src/guard/check-source.test.ts +159 -13
  378. package/src/guard/grace.test.ts +158 -0
  379. package/src/guard/index.ts +185 -29
  380. package/src/guard/types.ts +4 -2
  381. package/src/index.ts +51 -5
  382. package/src/nav/types.ts +84 -2
  383. package/src/notifications/bell.tsx +6 -3
  384. package/src/notifications/index.ts +1 -1
  385. package/src/router-tanstack/field.ts +35 -0
  386. package/src/router-tanstack/index.ts +28 -2
  387. package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
  388. package/src/router-tanstack/multi-search-param-store.ts +46 -77
  389. package/src/router-tanstack/search-param-store.ts +42 -159
  390. package/src/router-tanstack/search-store.router.test.tsx +882 -0
  391. package/src/router-tanstack/search-store.test.ts +578 -0
  392. package/src/router-tanstack/search-store.ts +546 -0
  393. package/src/router-tanstack/use-nav.test.tsx +187 -0
  394. package/src/router-tanstack/use-nav.ts +72 -14
  395. package/src/section/index.ts +1 -0
  396. package/src/section/section.module.css +104 -0
  397. package/src/section/section.module.css.d.ts +11 -0
  398. package/src/section/section.test.tsx +204 -0
  399. package/src/section/section.tsx +220 -0
  400. package/src/shell/app-breadcrumbs.tsx +44 -31
  401. package/src/shell/app-header.module.css +77 -37
  402. package/src/shell/app-header.module.css.d.ts +2 -1
  403. package/src/shell/app-mobile-nav.module.css +8 -0
  404. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  405. package/src/shell/app-mobile-nav.tsx +147 -8
  406. package/src/shell/app-sidebar-account.tsx +2 -2
  407. package/src/shell/app-sidebar.module.css +303 -20
  408. package/src/shell/app-sidebar.module.css.d.ts +19 -1
  409. package/src/shell/app-sidebar.test.tsx +456 -41
  410. package/src/shell/app-sidebar.tsx +276 -140
  411. package/src/shell/index.test.tsx +291 -5
  412. package/src/shell/index.tsx +137 -38
  413. package/src/shell/mobile-nav-model.test.ts +228 -7
  414. package/src/shell/mobile-nav-model.ts +53 -3
  415. package/src/shell/nav-count-badge.tsx +1 -1
  416. package/src/shell/page-bar.module.css +178 -0
  417. package/src/shell/page-bar.module.css.d.ts +17 -0
  418. package/src/shell/page-bar.test.tsx +316 -0
  419. package/src/shell/page-bar.tsx +315 -0
  420. package/src/shell/sidebar-block-model.ts +83 -0
  421. package/src/shell/sidebar-blocks.tsx +301 -0
  422. package/src/shell/sidebar-search.module.css +26 -0
  423. package/src/shell/sidebar-search.module.css.d.ts +2 -0
  424. package/src/shell/sidebar-search.tsx +71 -2
  425. package/src/state/fields.test.tsx +512 -0
  426. package/src/state/fields.ts +1150 -0
  427. package/src/state/persisted.test.ts +91 -0
  428. package/src/state/persisted.ts +261 -0
  429. package/src/state.ts +52 -240
  430. package/src/styles.css +13 -0
  431. package/src/surfaces.ts +204 -36
  432. package/src/theme/controls.module.css +16 -1
  433. package/src/theme/ctl-theme.test.tsx +275 -0
  434. package/src/theme/ctl-theme.tsx +138 -0
  435. package/src/theme/ctl-tier-coverage.test.ts +170 -0
  436. package/src/theme/density-relations.test.ts +106 -51
  437. package/src/theme/divider-law.test.ts +259 -0
  438. package/src/theme/icon-slot.module.css +47 -0
  439. package/src/theme/icon-slot.module.css.d.ts +8 -0
  440. package/src/theme/icon-slot.test.tsx +275 -0
  441. package/src/theme/icon-slot.tsx +59 -0
  442. package/src/theme/index.ts +165 -3
  443. package/src/theme/layout-rhythm-css.test.ts +72 -0
  444. package/src/theme/radius.test.ts +11 -1
  445. package/src/theme/segmented-control.module.css +20 -0
  446. package/src/theme/shadow-surfaces.test.ts +20 -7
  447. package/src/theme/spacing.test.ts +75 -27
  448. package/src/theme/use-basalt-spacing.ts +2 -2
  449. package/src/tokens/density.test.ts +7 -25
  450. package/src/tokens/index.ts +48 -8
  451. package/src/tokens/palette.ts +107 -73
  452. package/src/widget-header/delta-badge.test.tsx +60 -0
  453. package/src/widget-header/delta-badge.tsx +57 -0
  454. package/src/widget-header/index.ts +2 -0
  455. package/src/widget-header/no-mantine.test.ts +33 -0
  456. package/src/widget-header/widget-header.module.css +350 -0
  457. package/src/widget-header/widget-header.module.css.d.ts +25 -0
  458. package/src/widget-header/widget-header.test.tsx +187 -0
  459. package/src/widget-header/widget-header.tsx +213 -0
  460. package/agent/rules/basalt-agent.md +0 -452
  461. package/agent/rules/basalt-app.md +0 -246
  462. package/agent/rules/basalt-commands.md +0 -320
  463. package/agent/rules/basalt-content.md +0 -409
  464. package/agent/rules/basalt-data.md +0 -329
  465. package/agent/rules/basalt-forms.md +0 -186
  466. package/agent/rules/basalt-notifications.md +0 -264
  467. package/agent/rules/basalt-query.md +0 -187
  468. package/agent/rules/basalt-router.md +0 -336
  469. package/dist/content/article-filter-bar.d.ts +0 -40
  470. package/dist/content/article-filter-bar.d.ts.map +0 -1
  471. package/dist/content/article-filter-bar.js +0 -56
  472. package/dist/shell/page-header.d.ts +0 -13
  473. package/dist/shell/page-header.d.ts.map +0 -1
  474. package/dist/shell/page-header.js +0 -23
  475. package/src/content/article-filter-bar.tsx +0 -88
  476. package/src/shell/page-header.tsx +0 -35
@@ -0,0 +1,291 @@
1
+ /**
2
+ * `NumberFilter` — one number, one pill (`docs/CONTROLS-SPEC.md` §3), over a `field.number`. Takes
3
+ * `field`, never `value`/`onChange`/`size` (C2/C5), like every other control on this subpath.
4
+ *
5
+ * **Two forms, and `options` is the whole switch.** With `options` the filter is a pill plus a radio
6
+ * list — the same body `SelectFilter` renders, reached through `EnumFilter`'s `ChoiceHandle`, so a
7
+ * numeric preset set (`1` / `2` / `7` nights) is one idiom with the enum filters rather than a
8
+ * second spelling of the same choice. Without `options` it is a pill whose popover holds a `ctl`
9
+ * `NumberInput` with its steppers, and the sheet form is that input in a full-width row.
10
+ *
11
+ * **A threshold is a NUMBER, not a string enum, and that is why this control exists.** Both
12
+ * consumers that needed one wrote around its absence in opposite directions: linewatch kept a raw
13
+ * `SegmentedControl` over `minDuration` (a `control-outside-home` warn, law C1), and argo widened
14
+ * `nights` into a string enum — which puts `'3'` in the URL, makes every comparison a parse, and
15
+ * closes a set that was never closed. `field.number` already carries the codec, the clamp and both
16
+ * lanes; the only thing missing was the control.
17
+ *
18
+ * **The stepper applies on blur or Enter, never per keystroke.** A number is typed digit by digit,
19
+ * so a live write navigates on `4`, `42`, `420` and lands three values in the loader for one
20
+ * intended threshold — and the middle two are real values the store would clamp and persist.
21
+ * `SearchFilter` debounces for the same reason; a number has an explicit commit point (leaving the
22
+ * field, pressing Enter) and a phrase does not, so this one commits rather than waits.
23
+ *
24
+ * **`min`/`max`/`int` come off the HANDLE, never the call site.** `field.number({ min, max, int })`
25
+ * declares them, the codec clamps to them on write, and the handle republishes all three — so the
26
+ * `NumberInput` bounds its own stepper and `int` refuses decimals outright. They are not props here
27
+ * on purpose: they belong to the field, which is the thing that validates the URL, and a second copy
28
+ * at the call site is a second answer to the same question that stops matching the moment the field
29
+ * moves. The clamp is still the backstop for a value that arrives from outside the input (a
30
+ * hand-typed URL, a stale deep link), and the draft follows the field down when it fires (see
31
+ * {@link useCommittedNumber}).
32
+ *
33
+ * @example
34
+ * // A preset set — a pill plus a radio list, exactly like SelectFilter.
35
+ * // nights: field.number({ fallback: 2, min: 1, max: 14, int: true })
36
+ * <NumberFilter
37
+ * field={booking.field.nights}
38
+ * label="Nights"
39
+ * options={[
40
+ * { value: 1, label: '1 night' },
41
+ * { value: 2, label: '2 nights' },
42
+ * { value: 7, label: 'A week' },
43
+ * ]}
44
+ * />
45
+ *
46
+ * @example
47
+ * // An open threshold — a pill holding a stepper, applied on blur or Enter.
48
+ * // minDuration: field.number({ fallback: 0, min: 0, max: 600 })
49
+ * <NumberFilter field={lines.field.minDuration} label="Min duration" step={30} />
50
+ */
51
+ import { NumberInput } from '@mantine/core'
52
+ import { useEffect, useRef, useState } from 'react'
53
+ import type { ReactNode } from 'react'
54
+ import type { FieldHandle, NumberField } from '../state'
55
+ import classes from './controls.module.css'
56
+ import { EnumFilter } from './enum-filter'
57
+ import type { ChoiceHandle } from './enum-filter'
58
+ import { useFilterRegistration, useFilterSurface } from './filter-context'
59
+ import { FilterPill } from './filter-pill'
60
+ import { SheetField, useControlName } from './filter-sheet'
61
+
62
+ /** One row of a numeric preset set. Narrower than `FilterOption` — a preset is never `disabled`:
63
+ * the set is declared at the call site, so a row that should not be offered is left out. */
64
+ export type NumberFilterOption = {
65
+ readonly value: number
66
+ readonly label: string
67
+ }
68
+
69
+ export type NumberFilterProps = {
70
+ readonly field: FieldHandle<NumberField>
71
+ /** The popover/sheet heading and the accessible name — never the pill text, which reads the
72
+ * VALUE (see `EnumFilter`'s doc for why a pill may only print what is selected). */
73
+ readonly label: string
74
+ readonly icon?: ReactNode
75
+ /** Present → the radio-list form. Absent → the stepper form. */
76
+ readonly options?: readonly NumberFilterOption[]
77
+ /**
78
+ * The stepper's increment. Ignored in the `options` form, which has no stepper.
79
+ *
80
+ * @default 1 — and for an `int: true` field that is also the only sane grain, so it is what the
81
+ * field's own declaration produces without this prop.
82
+ */
83
+ readonly step?: number
84
+ }
85
+
86
+ export function NumberFilter({ field, label, icon, options, step }: NumberFilterProps): ReactNode {
87
+ // Two BRANCHES, two components, and never a conditional hook: the radio form's state lives in
88
+ // `EnumFilter` and the stepper's in `NumberStepper`, so which one mounts is decided once by a prop
89
+ // that does not change across a mount's life.
90
+ if (options !== undefined) {
91
+ return (
92
+ <NumberChoice
93
+ field={field}
94
+ label={label}
95
+ options={options}
96
+ {...(icon !== undefined && { icon })}
97
+ />
98
+ )
99
+ }
100
+ return (
101
+ <NumberStepper
102
+ field={field}
103
+ label={label}
104
+ {...(icon !== undefined && { icon })}
105
+ {...(step !== undefined && { step })}
106
+ />
107
+ )
108
+ }
109
+
110
+ /**
111
+ * The `options` form. It renders no radio list of its own — it ADAPTS the number handle into
112
+ * `EnumFilter`'s `ChoiceHandle<string>` and hands it over, so the popover's `Radio.Group`, the
113
+ * sheet's `SheetOptionList`, the registration and the pill readout are the ones already tested for
114
+ * `SelectFilter`/`CompareFilter` rather than a numeric copy of all four.
115
+ *
116
+ * The adapter is a projection, not a store: the URL still holds a NUMBER (`nights=3`, not `'3'`),
117
+ * because every write goes back through `setValue(Number(next))` into the same field codec.
118
+ *
119
+ * `field.use()` is called HERE, at a component's top level, and the adapter's own `use()` just
120
+ * returns what it read. The other way round — a `use()` that calls the real hook when `EnumFilter`
121
+ * invokes it — works at runtime (the call site is stable) but makes a plain object method a hook,
122
+ * which is a thing neither `react/rules-of-hooks` nor a reader should have to reason about.
123
+ */
124
+ function NumberChoice({
125
+ field,
126
+ label,
127
+ icon,
128
+ options,
129
+ }: {
130
+ readonly field: FieldHandle<NumberField>
131
+ readonly label: string
132
+ readonly icon?: ReactNode
133
+ readonly options: readonly NumberFilterOption[]
134
+ }): ReactNode {
135
+ const [value, setValue] = field.use()
136
+ const rows = options.map((option) => ({ value: String(option.value), label: option.label }))
137
+ const choice: ChoiceHandle<string> = {
138
+ fallback: String(field.fallback),
139
+ options: rows,
140
+ use: () =>
141
+ [
142
+ String(value),
143
+ (next: string) => {
144
+ setValue(Number(next))
145
+ },
146
+ ] as const,
147
+ isDefault: (v) => field.isDefault(Number(v)),
148
+ clear: () => {
149
+ field.clear()
150
+ },
151
+ }
152
+
153
+ // `options` passed explicitly as well as through the handle: `EnumFilter` reads the PROP first
154
+ // (the runtime-catalogue override), and passing the same rows both ways keeps the two agreeing
155
+ // whichever branch of that lookup a future edit takes.
156
+ return (
157
+ <EnumFilter
158
+ field={choice}
159
+ label={label}
160
+ numeric
161
+ options={rows}
162
+ {...(icon !== undefined && { icon })}
163
+ />
164
+ )
165
+ }
166
+
167
+ /** The stepper form — a pill holding a `ctl` `NumberInput`, or a full-width sheet row. */
168
+ function NumberStepper({
169
+ field,
170
+ label,
171
+ icon,
172
+ step,
173
+ }: {
174
+ readonly field: FieldHandle<NumberField>
175
+ readonly label: string
176
+ readonly icon?: ReactNode
177
+ readonly step?: number
178
+ }): ReactNode {
179
+ const [value, setValue] = field.use()
180
+ const surface = useFilterSurface()
181
+ const inSheet = surface === 'sheet'
182
+ const isDefault = field.isDefault(value)
183
+ useFilterRegistration(!isDefault, () => {
184
+ field.clear()
185
+ })
186
+ const { labelId, nameProps } = useControlName(label, inSheet)
187
+ const draft = useCommittedNumber(value, setValue, { min: field.min, max: field.max })
188
+
189
+ const input = (
190
+ <NumberInput
191
+ {...nameProps}
192
+ size="ctl"
193
+ // The FIELD's bounds, straight off the handle — so the stepper stops at the limit and a typed
194
+ // value cannot exceed it, instead of being silently corrected one commit later. The codec's
195
+ // clamp stays the backstop for everything that does not come through this box.
196
+ {...(field.min !== undefined && { min: field.min })}
197
+ {...(field.max !== undefined && { max: field.max })}
198
+ // `int: true` is refused by the codec, not rounded, so the input must refuse it too — a `.5`
199
+ // typed into an integer field would otherwise decode to `null` and resurrect the fallback.
200
+ {...(field.int && { allowDecimal: false })}
201
+ {...(step !== undefined ? { step } : field.int && { step: 1 })}
202
+ value={draft.value}
203
+ onChange={draft.set}
204
+ onBlur={draft.commit}
205
+ onKeyDown={(event) => {
206
+ if (event.key !== 'Enter') return
207
+ // A filter is not inside a form here, but a consumer's page may be — an Enter that submits
208
+ // the page instead of committing the threshold is the one failure mode worth pre-empting.
209
+ event.preventDefault()
210
+ draft.commit()
211
+ }}
212
+ />
213
+ )
214
+
215
+ if (inSheet) {
216
+ return (
217
+ <SheetField label={label} labelId={labelId}>
218
+ {input}
219
+ </SheetField>
220
+ )
221
+ }
222
+
223
+ return (
224
+ <FilterPill
225
+ label={String(value)}
226
+ numeric
227
+ active={!isDefault}
228
+ {...(icon !== undefined && { icon })}
229
+ >
230
+ <div className={classes.optionList}>{input}</div>
231
+ </FilterPill>
232
+ )
233
+ }
234
+
235
+ /**
236
+ * A local draft that lands on the field only when the user is DONE with it (blur, Enter), and that
237
+ * follows the field when it changes from outside the input — `Reset all`, a back navigation, a deep
238
+ * link, and the codec's own clamp.
239
+ *
240
+ * The clamp is the interesting direction, and it is why `bounds` is a parameter rather than left to
241
+ * the codec. `commit` clamps to the field's OWN limits before it compares, so the readout follows
242
+ * the clamp even when the store does not move: a second out-of-range `9999` against `max: 600` over
243
+ * a stored `600` writes nothing (the value is already `600`), which means `[value]` never changes and
244
+ * the effect below never runs — and the input went on displaying `9999` over a URL holding `600`,
245
+ * exactly the readout this hook exists to prevent. Only the FIRST out-of-range commit was ever
246
+ * healed.
247
+ *
248
+ * `seen` (the number this hook last committed) plus the effect stay as the backstop for a change that
249
+ * did not come through this box at all — `Reset all`, a deep link, a back navigation — and for a
250
+ * codec clamp the bounds here cannot predict.
251
+ */
252
+ function useCommittedNumber(
253
+ value: number,
254
+ setValue: (next: number) => void,
255
+ bounds: { min?: number | undefined; max?: number | undefined },
256
+ ): { value: number | string; set: (next: number | string) => void; commit: () => void } {
257
+ const [draft, setDraft] = useState<number | string>(value)
258
+ const seen = useRef(value)
259
+ const commitRef = useRef(setValue)
260
+ commitRef.current = setValue
261
+
262
+ useEffect(() => {
263
+ if (seen.current === value) return
264
+ seen.current = value
265
+ setDraft(value)
266
+ }, [value])
267
+
268
+ return {
269
+ value: draft,
270
+ set: setDraft,
271
+ commit: () => {
272
+ const parsed = typeof draft === 'number' ? draft : Number.parseFloat(draft)
273
+ // An empty or half-typed box ('', '-', '1e') is not a value — restore the field's, rather
274
+ // than committing `NaN` for the codec to fall back on and calling that the user's intent.
275
+ if (!Number.isFinite(parsed)) {
276
+ setDraft(value)
277
+ return
278
+ }
279
+ // Clamp to what the codec will actually store, then show THAT — a draft the store is about to
280
+ // refuse is a lie whether or not the store's value moves. `int` needs no handling: the input
281
+ // runs `allowDecimal: false` on an integer field, so a decimal never reaches here.
282
+ let next = parsed
283
+ if (bounds.min !== undefined && next < bounds.min) next = bounds.min
284
+ if (bounds.max !== undefined && next > bounds.max) next = bounds.max
285
+ setDraft(next)
286
+ if (next === value) return
287
+ seen.current = next
288
+ commitRef.current(next)
289
+ },
290
+ }
291
+ }
@@ -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
+ })
@@ -0,0 +1,137 @@
1
+ /**
2
+ * C2 and C5 as COMPILER assertions. The claim "a basalt filter has no `value`/`onChange`/`size`" is
3
+ * only worth making if writing one fails the build, so each `@ts-expect-error` below is the actual
4
+ * enforcement: `bunx tsc --noEmit` fails if any of them stops being an error, which is what happens
5
+ * the moment someone widens a prop type back open.
6
+ *
7
+ * `tsc` covers `src/**` including test files, so this file is checked by the ordinary typecheck —
8
+ * there is nothing extra to run and nothing to remember.
9
+ */
10
+ import { describe, expect, test } from 'bun:test'
11
+ import type { ReactNode } from 'react'
12
+ import { createLocalStore, field } from '../state'
13
+ import { CompareFilter } from './compare-filter'
14
+ import { MultiSelectFilter } from './multi-select-filter'
15
+ import { NumberFilter } from './number-filter'
16
+ import { RangeFilter } from './range-filter'
17
+ import { SearchFilter } from './search-filter'
18
+ import { SelectFilter } from './select-filter'
19
+ import { ToggleFilter } from './toggle-filter'
20
+ import { ViewTabs } from './view-tabs'
21
+
22
+ const local = createLocalStore({
23
+ key: 'props-contract',
24
+ fields: {
25
+ currency: field.enum(['USD', 'EUR'], 'USD'),
26
+ compare: field.enum(['none', 'previous', 'year'] as const, 'none'),
27
+ channels: field.multi(['web', 'email'], []),
28
+ range: field.range({ presets: ['7d', '30d'], fallback: '30d', custom: true }),
29
+ errorsOnly: field.boolean(false),
30
+ q: field.string(),
31
+ nights: field.number({ fallback: 2, min: 1, max: 14, int: true }),
32
+ },
33
+ })
34
+
35
+ /** A runtime catalogue — the shape both `options` props take (`FilterOption[]`). */
36
+ const PROJECTS = [
37
+ { value: 'argo', label: 'Argo' },
38
+ { value: 'linewatch', label: 'Linewatch', disabled: true },
39
+ ]
40
+ const CHANNELS = [{ value: 'web', label: 'web · 1.2k' }]
41
+
42
+ /** Never rendered — the file's product is the type errors below, not a DOM assertion. */
43
+ function Rejected(): ReactNode {
44
+ return (
45
+ <>
46
+ {/* @ts-expect-error C2 — a filter has no `value`; the field owns it. */}
47
+ <SelectFilter field={local.field.currency} label="Currency" value="EUR" />
48
+ {/* @ts-expect-error C2 — nor an `onChange`; the write goes through the field. */}
49
+ <SelectFilter field={local.field.currency} label="Currency" onChange={() => {}} />
50
+ {/* @ts-expect-error C5 — the home sets the tier; a control carries no `size`. */}
51
+ <SelectFilter field={local.field.currency} label="Currency" size="xs" />
52
+ {/* @ts-expect-error C5 — nor a responsive override; the swap is the control's own. */}
53
+ <ViewTabs field={local.field.currency} visibleFrom="sm" />
54
+ {/* @ts-expect-error C2 — a tab is store state too. */}
55
+ <ViewTabs field={local.field.currency} value="USD" />
56
+ {/* @ts-expect-error a boolean field is not an enum field. */}
57
+ <ToggleFilter field={local.field.currency} label="Errors only" />
58
+ {/* @ts-expect-error a multi field is not an enum field. */}
59
+ <SelectFilter field={local.field.channels} label="Channels" />
60
+ {/* @ts-expect-error an enum field is not a range field. */}
61
+ <RangeFilter field={local.field.currency} />
62
+ {/* @ts-expect-error a string field is not a boolean field. */}
63
+ <ToggleFilter field={local.field.q} label="Errors only" />
64
+ {/* @ts-expect-error CompareFilter's enum is the three basalt names, not any enum. */}
65
+ <CompareFilter field={local.field.currency} />
66
+ {/* @ts-expect-error `label` is required on SelectFilter — the pill needs a name at rest. */}
67
+ <SelectFilter field={local.field.currency} />
68
+ {/* A string field declares no values, so `options` is the only possible row source — without
69
+ it the popover would render empty. Two overloads rather than one union prop type precisely
70
+ so the message names `options`; see `SelectFilter`'s own doc. */}
71
+ {/* @ts-expect-error a string field needs `options` — nothing else can supply the rows. */}
72
+ <SelectFilter field={local.field.q} label="Project" />
73
+ {/* @ts-expect-error `options` does not widen a MULTI field into a single-select either. */}
74
+ <SelectFilter field={local.field.channels} label="Channels" options={PROJECTS} />
75
+ {/* @ts-expect-error C2 — a number filter is store-bound too; there is no `value`. */}
76
+ <NumberFilter field={local.field.nights} label="Nights" value={3} />
77
+ {/* @ts-expect-error C5 — the home sets the tier. `step` is the stepper's increment, not a size. */}
78
+ <NumberFilter field={local.field.nights} label="Nights" size="xs" />
79
+ {/* @ts-expect-error an enum field is not a number field — the whole point of the control. */}
80
+ <NumberFilter field={local.field.currency} label="Nights" />
81
+ {/* @ts-expect-error `label` is required — the popover and the sheet both need a heading. */}
82
+ <NumberFilter field={local.field.nights} />
83
+ {/* NumberFilter's options are NUMBERS. A string here is the exact shape argo reached for when
84
+ it widened `nights` into an enum, and it has to be a type error rather than a silent
85
+ `Number('7')` at the boundary. */}
86
+ <NumberFilter
87
+ field={local.field.nights}
88
+ label="Nights"
89
+ // @ts-expect-error a numeric preset set takes `value: number`, never a numeral string.
90
+ options={[{ value: '7', label: 'A week' }]}
91
+ />
92
+ {/* @ts-expect-error `min`/`max` belong to the FIELD, which is what validates the URL. */}
93
+ <NumberFilter field={local.field.nights} label="Nights" min={1} />
94
+ </>
95
+ )
96
+ }
97
+
98
+ /** The same call sites, spelled correctly — so a type error above cannot be a false positive. */
99
+ function Accepted(): ReactNode {
100
+ return (
101
+ <>
102
+ <SelectFilter field={local.field.currency} label="Currency" clearable />
103
+ {/* A runtime catalogue over a closed enum — `options` is optional there. */}
104
+ <SelectFilter
105
+ field={local.field.currency}
106
+ label="Currency"
107
+ options={[{ value: 'USD', label: 'USD · 1.00' }]}
108
+ />
109
+ {/* A string field WITH options — the id set an enum cannot close over. */}
110
+ <SelectFilter field={local.field.q} label="Project" options={PROJECTS} />
111
+ <MultiSelectFilter field={local.field.channels} label="All channels" options={CHANNELS} />
112
+ <CompareFilter field={local.field.compare} />
113
+ <MultiSelectFilter field={local.field.channels} label="All channels" noun="channels" />
114
+ <RangeFilter field={local.field.range} />
115
+ <ToggleFilter field={local.field.errorsOnly} label="Errors only" />
116
+ <SearchFilter field={local.field.q} placeholder="Search" />
117
+ <ViewTabs field={local.field.currency} />
118
+ {/* Both forms, spelled correctly: the preset set, and the open stepper. */}
119
+ <NumberFilter
120
+ field={local.field.nights}
121
+ label="Nights"
122
+ options={[
123
+ { value: 1, label: '1 night' },
124
+ { value: 7, label: 'A week' },
125
+ ]}
126
+ />
127
+ <NumberFilter field={local.field.nights} label="Nights" step={2} />
128
+ </>
129
+ )
130
+ }
131
+
132
+ describe('props contract', () => {
133
+ test('both fixtures exist — tsc, not bun, is the assertion (see this file header)', () => {
134
+ expect(typeof Rejected).toBe('function')
135
+ expect(typeof Accepted).toBe('function')
136
+ })
137
+ })