@justanarthur/modo-ui 0.6.0-canary.8 → 0.6.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 (490) hide show
  1. package/README.md +66 -164
  2. package/dist/base.css +4 -141
  3. package/dist/blocks/sidebar-app/index.js +26 -40
  4. package/dist/components/AI/input-message/index.js +16 -23
  5. package/dist/components/AI/thinking-indicator/index.js +6 -9
  6. package/dist/components/accordion/index.d.ts +11 -39
  7. package/dist/components/accordion/index.js +13 -18
  8. package/dist/components/badge/index.d.ts +11 -18
  9. package/dist/components/badge/index.js +7 -12
  10. package/dist/components/button/index.d.ts +17 -53
  11. package/dist/components/button/index.js +6 -11
  12. package/dist/components/card/index.d.ts +9 -32
  13. package/dist/components/card/index.js +11 -17
  14. package/dist/components/copy/index.d.ts +8 -41
  15. package/dist/components/copy/index.js +9 -15
  16. package/dist/components/inputs/checkbox/index.d.ts +9 -28
  17. package/dist/components/inputs/checkbox/index.js +13 -18
  18. package/dist/components/inputs/color-picker/index.d.ts +14 -22
  19. package/dist/components/inputs/color-picker/index.js +18 -26
  20. package/dist/components/inputs/combobox/index.d.ts +20 -85
  21. package/dist/components/inputs/combobox/index.js +18 -27
  22. package/dist/components/inputs/date-time/calendar/index.d.ts +18 -24
  23. package/dist/components/inputs/date-time/calendar/index.js +11 -15
  24. package/dist/components/inputs/date-time/date-picker/index.d.ts +16 -105
  25. package/dist/components/inputs/date-time/date-picker/index.js +25 -35
  26. package/dist/components/inputs/date-time/date-range-picker/index.d.ts +12 -106
  27. package/dist/components/inputs/date-time/date-range-picker/index.js +24 -35
  28. package/dist/components/inputs/date-time/duration-picker/index.d.ts +13 -66
  29. package/dist/components/inputs/date-time/duration-picker/index.js +24 -33
  30. package/dist/components/inputs/date-time/month-picker/index.d.ts +10 -60
  31. package/dist/components/inputs/date-time/month-picker/index.js +23 -32
  32. package/dist/components/inputs/date-time/time-picker/index.d.ts +10 -58
  33. package/dist/components/inputs/date-time/time-picker/index.js +24 -33
  34. package/dist/components/inputs/input/index.d.ts +13 -95
  35. package/dist/components/inputs/input/index.js +9 -18
  36. package/dist/components/inputs/radio/index.d.ts +5 -20
  37. package/dist/components/inputs/radio/index.js +12 -16
  38. package/dist/components/inputs/search/index.d.ts +3 -3
  39. package/dist/components/inputs/search/index.js +18 -26
  40. package/dist/components/inputs/select/index.d.ts +23 -99
  41. package/dist/components/inputs/select/index.js +14 -29
  42. package/dist/components/inputs/slider/index.d.ts +0 -9
  43. package/dist/components/inputs/slider/index.js +5 -8
  44. package/dist/components/inputs/switch/index.d.ts +2 -8
  45. package/dist/components/inputs/switch/index.js +12 -15
  46. package/dist/components/inputs/textarea/index.d.ts +1 -20
  47. package/dist/components/inputs/textarea/index.js +6 -9
  48. package/dist/components/navigation/sidebar/index.d.ts +21 -44
  49. package/dist/components/navigation/sidebar/index.js +31 -42
  50. package/dist/components/navigation/tabs/index.d.ts +2 -8
  51. package/dist/components/navigation/tabs/index.js +10 -15
  52. package/dist/components/navigation/tabs-subtle/index.js +10 -13
  53. package/dist/components/overlays/alert-dialog/index.d.ts +16 -135
  54. package/dist/components/overlays/alert-dialog/index.js +13 -23
  55. package/dist/components/overlays/command/index.d.ts +20 -25
  56. package/dist/components/overlays/command/index.js +17 -25
  57. package/dist/components/overlays/context-menu/index.d.ts +17 -39
  58. package/dist/components/overlays/context-menu/index.js +18 -29
  59. package/dist/components/overlays/dialog/index.d.ts +16 -42
  60. package/dist/components/overlays/dialog/index.js +12 -20
  61. package/dist/components/overlays/dropdown/index.d.ts +34 -304
  62. package/dist/components/overlays/dropdown/index.js +17 -44
  63. package/dist/components/overlays/popover/index.d.ts +12 -38
  64. package/dist/components/overlays/popover/index.js +9 -13
  65. package/dist/components/overlays/preview-card/index.js +8 -12
  66. package/dist/components/overlays/sheet/index.d.ts +8 -25
  67. package/dist/components/overlays/sheet/index.js +13 -19
  68. package/dist/components/overlays/toast/index.d.ts +50 -209
  69. package/dist/components/overlays/toast/index.js +10 -23
  70. package/dist/components/overlays/tooltip/index.d.ts +21 -123
  71. package/dist/components/overlays/tooltip/index.js +6 -16
  72. package/dist/components/table/index.d.ts +8 -63
  73. package/dist/components/table/index.js +9 -20
  74. package/dist/index.d.ts +1048 -2999
  75. package/dist/index.js +82 -231
  76. package/dist/preset.d.ts +0 -22
  77. package/dist/preset.js +2 -24
  78. package/dist/primitives/code/index.js +5 -8
  79. package/dist/primitives/craft/index.js +6 -10
  80. package/dist/primitives/fluid-hover/index.js +8 -10
  81. package/dist/primitives/morph/index.js +10 -16
  82. package/dist/primitives/motion/index.js +8 -11
  83. package/dist/primitives/scroll-area/index.js +3 -5
  84. package/dist/primitives/sizes/index.js +4 -6
  85. package/dist/primitives/surface/index.js +4 -6
  86. package/dist/shared/{components-4y816hwp.js → components-0q0sqfh4.js} +49 -27
  87. package/dist/shared/components-0sn7ypjw.js +61 -0
  88. package/dist/shared/{components-vc864c51.js → components-11da9ajg.js} +5 -8
  89. package/dist/shared/components-1434p1mh.js +123 -0
  90. package/dist/shared/{components-t2a3qmy9.js → components-1z6tdadk.js} +24 -40
  91. package/dist/shared/{components-qs4rkqsj.js → components-27nnvrwe.js} +14 -15
  92. package/dist/shared/{components-av5t26rc.js → components-2dxkv6zm.js} +7 -8
  93. package/dist/shared/{components-wqrvqq5p.js → components-2ecg8svx.js} +152 -234
  94. package/dist/shared/{components-3ydr2fqe.js → components-2ph6t61s.js} +34 -37
  95. package/dist/shared/{components-6gk118w3.js → components-39jq9va1.js} +24 -29
  96. package/dist/shared/components-3ky44908.js +9 -0
  97. package/dist/shared/{components-y1qvbawa.js → components-3mkepka5.js} +25 -28
  98. package/dist/shared/{components-xqszvkj4.js → components-4700p9kz.js} +22 -24
  99. package/dist/shared/{components-yhgq0qxx.js → components-59efxjd9.js} +12 -13
  100. package/dist/shared/{components-9j9abkct.js → components-5vgpnxgy.js} +27 -51
  101. package/dist/shared/{components-8tt3xbb5.js → components-5xc2j8jz.js} +18 -19
  102. package/dist/shared/{components-jymmaevv.js → components-6p8tfcd6.js} +29 -57
  103. package/dist/shared/{components-4nck8acq.js → components-7r3etddy.js} +54 -19
  104. package/dist/shared/{components-skfd7kff.js → components-8snj490h.js} +32 -78
  105. package/dist/shared/{components-ctqmge8t.js → components-b0eac83j.js} +30 -9
  106. package/dist/shared/{components-9sttq78t.js → components-b2wyq2cq.js} +59 -41
  107. package/dist/shared/{components-vkwq2k96.js → components-b3c5et5b.js} +38 -49
  108. package/dist/shared/{components-rrbpmfx4.js → components-c6s6b43g.js} +2 -3
  109. package/dist/shared/{components-pw06rbxq.js → components-chacnk61.js} +19 -19
  110. package/dist/shared/{components-71nbt03m.js → components-cv16mryv.js} +7 -26
  111. package/dist/shared/{components-eqze3y30.js → components-d6xtjht3.js} +30 -35
  112. package/dist/shared/{components-5y5shqfd.js → components-dk0gjr5a.js} +18 -19
  113. package/dist/shared/{components-khspp26b.js → components-fzrq6tr3.js} +34 -37
  114. package/dist/shared/{components-km7fhgbk.js → components-gc5vtq2f.js} +30 -66
  115. package/dist/shared/components-gf5s6ap9.js +42 -0
  116. package/dist/shared/{components-qqcmtrbv.js → components-gz8zf3ec.js} +55 -56
  117. package/dist/shared/{components-mattt5jp.js → components-h2ajmhwn.js} +35 -107
  118. package/dist/shared/{components-awra5sff.js → components-hqeq3w9q.js} +16 -24
  119. package/dist/shared/{components-5tzhqbcw.js → components-hw9yk32z.js} +24 -36
  120. package/dist/shared/components-hyq23phv.js +310 -0
  121. package/dist/shared/{components-8mt5bsv6.js → components-jbbmdr74.js} +22 -41
  122. package/dist/shared/{components-dcc02hs2.js → components-jjw69c4c.js} +4 -7
  123. package/dist/shared/{components-2syq4mwc.js → components-jthe1pwb.js} +420 -139
  124. package/dist/shared/{components-r7q1zm7r.js → components-jwst3vtj.js} +76 -224
  125. package/dist/shared/{components-qdm3yh26.js → components-kvr7f9hy.js} +10 -19
  126. package/dist/shared/{components-s8yqk2dc.js → components-kwa6mrf9.js} +11 -46
  127. package/dist/shared/{components-de99ykt7.js → components-men63rwj.js} +2 -3
  128. package/dist/shared/{components-6b0zb8z3.js → components-mjthm1kf.js} +9 -13
  129. package/dist/shared/{components-2c64r8c9.js → components-n42c79bz.js} +22 -23
  130. package/dist/shared/{components-tz1fv35x.js → components-n7mjr357.js} +2 -3
  131. package/dist/shared/components-ng72k97e.js +128 -0
  132. package/dist/shared/{components-dyae0y7h.js → components-pbmqctfa.js} +65 -169
  133. package/dist/shared/{components-px2zm7dc.js → components-q5xj5c16.js} +8 -9
  134. package/dist/shared/components-raknhppb.js +64 -0
  135. package/dist/shared/{components-8rb25gbe.js → components-rr7a6ja9.js} +27 -59
  136. package/dist/shared/{components-5012r6gr.js → components-s083f38z.js} +34 -164
  137. package/dist/shared/components-s5ngg32c.js +267 -0
  138. package/dist/shared/components-sn1rxx1d.js +143 -0
  139. package/dist/shared/{components-r8eqs7gz.js → components-ta7shw28.js} +4 -4
  140. package/dist/shared/{components-9as3rf68.js → components-vc7vq5nz.js} +3 -6
  141. package/dist/shared/{components-ft3zdc79.js → components-vfbhxf5r.js} +5 -6
  142. package/dist/shared/components-vynjtrk6.js +19 -0
  143. package/dist/shared/{components-w0xq6q9k.js → components-w1r67ag9.js} +149 -294
  144. package/dist/shared/{components-z0bqtvcp.js → components-w90endve.js} +72 -71
  145. package/dist/shared/{components-t5pk5nmc.js → components-wvp1m52g.js} +102 -149
  146. package/dist/shared/components-wy9jpqny.js +146 -0
  147. package/dist/shared/{components-5sxjeh6h.js → components-y9y0g8er.js} +28 -52
  148. package/dist/shared/components-ypp01w3p.js +986 -0
  149. package/dist/shared/{components-t50hrswb.js → components-zfnmn5d4.js} +36 -82
  150. package/dist/shared/{components-jqpefjsn.js → components-zrkzcayz.js} +6 -54
  151. package/dist/shared/{components-vf45j12c.js → components-zx6b6yby.js} +18 -4
  152. package/dist/styles.css +32 -320
  153. package/dist/tailwind.css +0 -16
  154. package/dist/tokens.css +0 -67
  155. package/package.json +4 -227
  156. package/DESIGN.md +0 -623
  157. package/blocks/sidebar-app/examples.mdx +0 -33
  158. package/blocks/sidebar-app/index.tsx +0 -218
  159. package/blocks/sidebar-app/inset-topbar.tsx +0 -25
  160. package/blocks/sidebar-app/menu-grid.ts +0 -26
  161. package/blocks/sidebar-app/nav-data.ts +0 -36
  162. package/blocks/sidebar-app/search-field.tsx +0 -48
  163. package/blocks/sidebar-app/user-footer.tsx +0 -67
  164. package/blocks/sidebar-app/workspace-header.tsx +0 -129
  165. package/components/AI/input-message/examples.mdx +0 -130
  166. package/components/AI/input-message/file-thumbnail.tsx +0 -173
  167. package/components/AI/input-message/index.tsx +0 -1385
  168. package/components/AI/thinking-indicator/examples.mdx +0 -27
  169. package/components/AI/thinking-indicator/index.tsx +0 -210
  170. package/components/accordion/examples.mdx +0 -233
  171. package/components/accordion/index.tsx +0 -1071
  172. package/components/avatar/examples.mdx +0 -95
  173. package/components/avatar/index.tsx +0 -129
  174. package/components/badge/change-demo.tsx +0 -29
  175. package/components/badge/examples.mdx +0 -124
  176. package/components/badge/index.tsx +0 -225
  177. package/components/badge/variants.ts +0 -73
  178. package/components/button/examples.mdx +0 -168
  179. package/components/button/index.tsx +0 -424
  180. package/components/button/loading-demo.tsx +0 -25
  181. package/components/button/variants.ts +0 -168
  182. package/components/card/examples.mdx +0 -251
  183. package/components/card/index.tsx +0 -1070
  184. package/components/copy/examples.mdx +0 -125
  185. package/components/copy/index.tsx +0 -662
  186. package/components/inputs/checkbox/examples.mdx +0 -87
  187. package/components/inputs/checkbox/index.tsx +0 -611
  188. package/components/inputs/checkbox/select-all-demo.tsx +0 -48
  189. package/components/inputs/color-picker/examples.mdx +0 -111
  190. package/components/inputs/color-picker/index.tsx +0 -1981
  191. package/components/inputs/color-picker/variants.ts +0 -401
  192. package/components/inputs/combobox/examples.mdx +0 -366
  193. package/components/inputs/combobox/index.tsx +0 -1527
  194. package/components/inputs/combobox/remote-demo.tsx +0 -105
  195. package/components/inputs/date-time/calendar/examples.mdx +0 -45
  196. package/components/inputs/date-time/calendar/index.tsx +0 -370
  197. package/components/inputs/date-time/calendar/use-month-window.ts +0 -173
  198. package/components/inputs/date-time/date-picker/examples.mdx +0 -243
  199. package/components/inputs/date-time/date-picker/index.tsx +0 -476
  200. package/components/inputs/date-time/date-picker/masked-date-input.tsx +0 -348
  201. package/components/inputs/date-time/date-range-picker/examples.mdx +0 -93
  202. package/components/inputs/date-time/date-range-picker/index.tsx +0 -525
  203. package/components/inputs/date-time/duration-picker/examples.mdx +0 -40
  204. package/components/inputs/date-time/duration-picker/index.tsx +0 -253
  205. package/components/inputs/date-time/month-picker/examples.mdx +0 -45
  206. package/components/inputs/date-time/month-picker/index.tsx +0 -254
  207. package/components/inputs/date-time/picker-shell.tsx +0 -581
  208. package/components/inputs/date-time/time-columns.tsx +0 -94
  209. package/components/inputs/date-time/time-picker/examples.mdx +0 -57
  210. package/components/inputs/date-time/time-picker/index.tsx +0 -227
  211. package/components/inputs/field/examples.mdx +0 -117
  212. package/components/inputs/field/index.tsx +0 -268
  213. package/components/inputs/file-dropzone/examples.mdx +0 -83
  214. package/components/inputs/file-dropzone/file-row.tsx +0 -201
  215. package/components/inputs/file-dropzone/index.tsx +0 -316
  216. package/components/inputs/file-dropzone/upload-demo.tsx +0 -67
  217. package/components/inputs/file-dropzone/variants.ts +0 -94
  218. package/components/inputs/input/examples.mdx +0 -166
  219. package/components/inputs/input/index.tsx +0 -494
  220. package/components/inputs/input/password-demo.tsx +0 -30
  221. package/components/inputs/label/examples.mdx +0 -67
  222. package/components/inputs/label/index.tsx +0 -85
  223. package/components/inputs/number-input/examples.mdx +0 -69
  224. package/components/inputs/number-input/index.tsx +0 -221
  225. package/components/inputs/radio/examples.mdx +0 -77
  226. package/components/inputs/radio/index.tsx +0 -636
  227. package/components/inputs/search/examples.mdx +0 -74
  228. package/components/inputs/search/index.tsx +0 -333
  229. package/components/inputs/select/examples.mdx +0 -296
  230. package/components/inputs/select/index.tsx +0 -1044
  231. package/components/inputs/select/variants.ts +0 -32
  232. package/components/inputs/slider/examples.mdx +0 -153
  233. package/components/inputs/slider/index.tsx +0 -1867
  234. package/components/inputs/switch/examples.mdx +0 -96
  235. package/components/inputs/switch/index.tsx +0 -504
  236. package/components/inputs/textarea/examples.mdx +0 -126
  237. package/components/inputs/textarea/index.tsx +0 -207
  238. package/components/navigation/navigation-menu/examples.mdx +0 -136
  239. package/components/navigation/navigation-menu/index.tsx +0 -704
  240. package/components/navigation/pagination/examples.mdx +0 -74
  241. package/components/navigation/pagination/index.tsx +0 -401
  242. package/components/navigation/sidebar/examples.mdx +0 -325
  243. package/components/navigation/sidebar/index.tsx +0 -453
  244. package/components/navigation/sidebar/sidebar-core.tsx +0 -1408
  245. package/components/navigation/sidebar/sidebar-menu.tsx +0 -1495
  246. package/components/navigation/sidebar/sidebar.css +0 -74
  247. package/components/navigation/sidebar/variants.ts +0 -55
  248. package/components/navigation/tabs/examples.mdx +0 -89
  249. package/components/navigation/tabs/index.tsx +0 -494
  250. package/components/navigation/tabs-subtle/examples.mdx +0 -49
  251. package/components/navigation/tabs-subtle/index.tsx +0 -512
  252. package/components/overlays/alert-dialog/examples.mdx +0 -123
  253. package/components/overlays/alert-dialog/index.tsx +0 -276
  254. package/components/overlays/alert-dialog/pending-demo.tsx +0 -36
  255. package/components/overlays/command/examples.mdx +0 -113
  256. package/components/overlays/command/index.tsx +0 -425
  257. package/components/overlays/context-menu/examples.mdx +0 -56
  258. package/components/overlays/context-menu/index.tsx +0 -279
  259. package/components/overlays/dialog/examples.mdx +0 -165
  260. package/components/overlays/dialog/index.tsx +0 -451
  261. package/components/overlays/dropdown/dropdown-search.tsx +0 -395
  262. package/components/overlays/dropdown/examples.mdx +0 -444
  263. package/components/overlays/dropdown/index.tsx +0 -1241
  264. package/components/overlays/dropdown/menu-item.tsx +0 -510
  265. package/components/overlays/dropdown/menu-parts.tsx +0 -725
  266. package/components/overlays/popover/anchored-demo.tsx +0 -59
  267. package/components/overlays/popover/examples.mdx +0 -203
  268. package/components/overlays/popover/index.tsx +0 -317
  269. package/components/overlays/preview-card/examples.mdx +0 -61
  270. package/components/overlays/preview-card/index.tsx +0 -204
  271. package/components/overlays/sheet/examples.mdx +0 -101
  272. package/components/overlays/sheet/index.tsx +0 -307
  273. package/components/overlays/toast/examples.mdx +0 -85
  274. package/components/overlays/toast/global-demo.tsx +0 -72
  275. package/components/overlays/toast/index.tsx +0 -544
  276. package/components/overlays/toast/manager.ts +0 -193
  277. package/components/overlays/tooltip/examples.mdx +0 -106
  278. package/components/overlays/tooltip/index.tsx +0 -539
  279. package/components/progress/examples.mdx +0 -59
  280. package/components/progress/index.tsx +0 -174
  281. package/components/progress/progress.css +0 -49
  282. package/components/progress/step-demo.tsx +0 -27
  283. package/components/table/examples.mdx +0 -188
  284. package/components/table/index.tsx +0 -535
  285. package/components/table/sort-demo.tsx +0 -61
  286. package/dist/components/avatar/index.d.ts +0 -46
  287. package/dist/components/avatar/index.js +0 -19
  288. package/dist/components/badge/variants.d.ts +0 -33
  289. package/dist/components/badge/variants.js +0 -10
  290. package/dist/components/button/variants.d.ts +0 -39
  291. package/dist/components/button/variants.js +0 -8
  292. package/dist/components/inputs/color-picker/variants.d.ts +0 -61
  293. package/dist/components/inputs/color-picker/variants.js +0 -28
  294. package/dist/components/inputs/field/index.d.ts +0 -116
  295. package/dist/components/inputs/field/index.js +0 -29
  296. package/dist/components/inputs/file-dropzone/index.d.ts +0 -167
  297. package/dist/components/inputs/file-dropzone/index.js +0 -33
  298. package/dist/components/inputs/file-dropzone/variants.d.ts +0 -35
  299. package/dist/components/inputs/file-dropzone/variants.js +0 -12
  300. package/dist/components/inputs/label/index.d.ts +0 -32
  301. package/dist/components/inputs/label/index.js +0 -11
  302. package/dist/components/inputs/number-input/index.d.ts +0 -87
  303. package/dist/components/inputs/number-input/index.js +0 -21
  304. package/dist/components/inputs/select/variants.d.ts +0 -5
  305. package/dist/components/inputs/select/variants.js +0 -6
  306. package/dist/components/navigation/navigation-menu/index.d.ts +0 -126
  307. package/dist/components/navigation/navigation-menu/index.js +0 -41
  308. package/dist/components/navigation/pagination/index.d.ts +0 -75
  309. package/dist/components/navigation/pagination/index.js +0 -41
  310. package/dist/components/navigation/sidebar/variants.d.ts +0 -25
  311. package/dist/components/navigation/sidebar/variants.js +0 -28
  312. package/dist/components/progress/index.d.ts +0 -56
  313. package/dist/components/progress/index.js +0 -15
  314. package/dist/docs/index.d.ts +0 -83
  315. package/dist/docs/index.js +0 -288
  316. package/dist/docs/vite.d.ts +0 -31
  317. package/dist/docs/vite.js +0 -45
  318. package/dist/lib/date-locale.d.ts +0 -26
  319. package/dist/lib/date-locale.js +0 -24
  320. package/dist/lib/field-classes.d.ts +0 -149
  321. package/dist/lib/field-classes.js +0 -41
  322. package/dist/lib/field-variants.d.ts +0 -47
  323. package/dist/lib/field-variants.js +0 -26
  324. package/dist/lib/fluid-hover-highlight.d.ts +0 -112
  325. package/dist/lib/fluid-hover-highlight.js +0 -17
  326. package/dist/lib/focus-ring.d.ts +0 -18
  327. package/dist/lib/focus-ring.js +0 -11
  328. package/dist/lib/goo-indicator.d.ts +0 -46
  329. package/dist/lib/goo-indicator.js +0 -14
  330. package/dist/lib/icon-context.d.ts +0 -30
  331. package/dist/lib/icon-context.js +0 -12
  332. package/dist/lib/inert.d.ts +0 -9
  333. package/dist/lib/inert.js +0 -8
  334. package/dist/lib/morph-layers.d.ts +0 -93
  335. package/dist/lib/morph-layers.js +0 -13
  336. package/dist/lib/morph-part.d.ts +0 -21
  337. package/dist/lib/morph-part.js +0 -12
  338. package/dist/lib/popup.d.ts +0 -20
  339. package/dist/lib/popup.js +0 -12
  340. package/dist/lib/reduced-motion.d.ts +0 -9
  341. package/dist/lib/reduced-motion.js +0 -6
  342. package/dist/lib/row-index.d.ts +0 -10
  343. package/dist/lib/row-index.js +0 -8
  344. package/dist/lib/row-order.d.ts +0 -28
  345. package/dist/lib/row-order.js +0 -10
  346. package/dist/lib/scroll-column.d.ts +0 -31
  347. package/dist/lib/scroll-column.js +0 -19
  348. package/dist/lib/shape-context.d.ts +0 -30
  349. package/dist/lib/shape-context.js +0 -13
  350. package/dist/lib/shape-radius.d.ts +0 -19
  351. package/dist/lib/shape-radius.js +0 -6
  352. package/dist/lib/size-context.d.ts +0 -94
  353. package/dist/lib/size-context.js +0 -20
  354. package/dist/lib/size-scale.d.ts +0 -79
  355. package/dist/lib/size-scale.js +0 -8
  356. package/dist/lib/slot.d.ts +0 -39
  357. package/dist/lib/slot.js +0 -14
  358. package/dist/lib/springs.d.ts +0 -44
  359. package/dist/lib/springs.js +0 -8
  360. package/dist/lib/surface-classes.d.ts +0 -19
  361. package/dist/lib/surface-classes.js +0 -10
  362. package/dist/lib/surface-context.d.ts +0 -9
  363. package/dist/lib/surface-context.js +0 -8
  364. package/dist/lib/use-controllable-state.d.ts +0 -2
  365. package/dist/lib/use-controllable-state.js +0 -6
  366. package/dist/lib/use-fluid-hover.d.ts +0 -138
  367. package/dist/lib/use-fluid-hover.js +0 -14
  368. package/dist/lib/use-is-mobile.d.ts +0 -2
  369. package/dist/lib/use-is-mobile.js +0 -6
  370. package/dist/lib/use-keyboard-nav-gate.d.ts +0 -20
  371. package/dist/lib/use-keyboard-nav-gate.js +0 -7
  372. package/dist/lib/use-merge-split.d.ts +0 -47
  373. package/dist/lib/use-merge-split.js +0 -15
  374. package/dist/lib/use-morph.d.ts +0 -101
  375. package/dist/lib/use-morph.js +0 -20
  376. package/dist/lib/use-touch-primary.d.ts +0 -2
  377. package/dist/lib/use-touch-primary.js +0 -6
  378. package/dist/lib/utils.d.ts +0 -3
  379. package/dist/lib/utils.js +0 -6
  380. package/dist/primitives/separator/index.d.ts +0 -25
  381. package/dist/primitives/separator/index.js +0 -9
  382. package/dist/primitives/skeleton/index.d.ts +0 -35
  383. package/dist/primitives/skeleton/index.js +0 -13
  384. package/dist/shared/components-124epb7f.js +0 -53
  385. package/dist/shared/components-1kab3j44.js +0 -337
  386. package/dist/shared/components-26snnbs9.js +0 -322
  387. package/dist/shared/components-34txk9rj.js +0 -35
  388. package/dist/shared/components-3h92d94m.js +0 -23
  389. package/dist/shared/components-4vfm04wq.js +0 -82
  390. package/dist/shared/components-54nq988h.js +0 -123
  391. package/dist/shared/components-7s7pykk5.js +0 -281
  392. package/dist/shared/components-7zkhz3r8.js +0 -399
  393. package/dist/shared/components-8v644a0s.js +0 -298
  394. package/dist/shared/components-a4evvq8h.js +0 -271
  395. package/dist/shared/components-adkarj8n.js +0 -35
  396. package/dist/shared/components-b1e239g3.js +0 -28
  397. package/dist/shared/components-b94t6qtj.js +0 -143
  398. package/dist/shared/components-c9yr4ed1.js +0 -44
  399. package/dist/shared/components-cwg1d0w6.js +0 -254
  400. package/dist/shared/components-d3shbwqq.js +0 -95
  401. package/dist/shared/components-dhgk6mbt.js +0 -16
  402. package/dist/shared/components-drxrtnp2.js +0 -25
  403. package/dist/shared/components-ej4r7wmj.js +0 -482
  404. package/dist/shared/components-f7h57m6t.js +0 -59
  405. package/dist/shared/components-fejdzvqc.js +0 -448
  406. package/dist/shared/components-gx4h5rbb.js +0 -40
  407. package/dist/shared/components-gyt7my41.js +0 -328
  408. package/dist/shared/components-h6p86be3.js +0 -19
  409. package/dist/shared/components-h83az0bz.js +0 -33
  410. package/dist/shared/components-hdzrqqpx.js +0 -55
  411. package/dist/shared/components-krtx3m5b.js +0 -84
  412. package/dist/shared/components-kswxnm3h.js +0 -24
  413. package/dist/shared/components-mpa2y6x8.js +0 -277
  414. package/dist/shared/components-mrbaxt8q.js +0 -62
  415. package/dist/shared/components-pt5ftm4p.js +0 -38
  416. package/dist/shared/components-rpw4md2b.js +0 -179
  417. package/dist/shared/components-rtadhd9j.js +0 -64
  418. package/dist/shared/components-tz4gaf1v.js +0 -23
  419. package/dist/shared/components-wbbckbw8.js +0 -34
  420. package/dist/shared/components-xdqmt6ed.js +0 -20
  421. package/dist/shared/components-xjkdvnbg.js +0 -107
  422. package/dist/shared/components-z3v0n0de.js +0 -11
  423. package/dist/shared/components-z6dfqtyt.js +0 -1545
  424. package/dist/tokens/colors.css +0 -230
  425. package/dist/tokens/motion.css +0 -40
  426. package/dist/tokens/radius.css +0 -10
  427. package/dist/tokens/spacing.css +0 -9
  428. package/dist/tokens/typography.css +0 -119
  429. package/docs/chrome.css +0 -120
  430. package/docs/chrome.tsx +0 -283
  431. package/docs/index.ts +0 -9
  432. package/docs/vite.ts +0 -85
  433. package/index.ts +0 -60
  434. package/lib/date-locale.ts +0 -195
  435. package/lib/field-classes.ts +0 -121
  436. package/lib/field-variants.ts +0 -117
  437. package/lib/fluid-hover-highlight.tsx +0 -139
  438. package/lib/focus-ring.tsx +0 -46
  439. package/lib/goo-indicator.tsx +0 -261
  440. package/lib/icon-context.tsx +0 -310
  441. package/lib/inert.ts +0 -22
  442. package/lib/morph-layers.tsx +0 -102
  443. package/lib/morph-part.tsx +0 -49
  444. package/lib/popup.ts +0 -52
  445. package/lib/reduced-motion.ts +0 -19
  446. package/lib/row-index.tsx +0 -29
  447. package/lib/row-order.tsx +0 -126
  448. package/lib/scroll-column.tsx +0 -273
  449. package/lib/shape-context.tsx +0 -138
  450. package/lib/shape-radius.ts +0 -67
  451. package/lib/size-context.tsx +0 -66
  452. package/lib/size-scale.ts +0 -115
  453. package/lib/slot.ts +0 -115
  454. package/lib/springs.ts +0 -62
  455. package/lib/surface-classes.ts +0 -58
  456. package/lib/surface-context.tsx +0 -21
  457. package/lib/use-controllable-state.ts +0 -44
  458. package/lib/use-fluid-hover.ts +0 -541
  459. package/lib/use-is-mobile.ts +0 -25
  460. package/lib/use-keyboard-nav-gate.ts +0 -45
  461. package/lib/use-merge-split.tsx +0 -209
  462. package/lib/use-morph.ts +0 -557
  463. package/lib/use-touch-primary.tsx +0 -43
  464. package/lib/utils.ts +0 -37
  465. package/preset.ts +0 -199
  466. package/primitives/code/examples.mdx +0 -27
  467. package/primitives/code/index.tsx +0 -80
  468. package/primitives/craft/demos.tsx +0 -173
  469. package/primitives/craft/examples.mdx +0 -142
  470. package/primitives/craft/index.tsx +0 -76
  471. package/primitives/fluid-hover/examples.mdx +0 -84
  472. package/primitives/fluid-hover/index.tsx +0 -316
  473. package/primitives/morph/examples.mdx +0 -139
  474. package/primitives/morph/index.tsx +0 -192
  475. package/primitives/motion/examples.mdx +0 -100
  476. package/primitives/motion/index.tsx +0 -160
  477. package/primitives/scroll-area/examples.mdx +0 -58
  478. package/primitives/scroll-area/index.tsx +0 -204
  479. package/primitives/separator/examples.mdx +0 -30
  480. package/primitives/separator/index.tsx +0 -53
  481. package/primitives/sizes/examples.mdx +0 -167
  482. package/primitives/sizes/index.tsx +0 -89
  483. package/primitives/skeleton/examples.mdx +0 -51
  484. package/primitives/skeleton/index.tsx +0 -72
  485. package/primitives/skeleton/skeleton.css +0 -15
  486. package/primitives/surface/examples.mdx +0 -173
  487. package/primitives/surface/index.tsx +0 -109
  488. package/primitives/surface/surface.css +0 -176
  489. /package/dist/shared/{components-3ey0hzpb.js → components-1v7rxytx.js} +0 -0
  490. /package/dist/shared/{components-jcnjay5g.js → components-ezxhrmer.js} +0 -0
@@ -1,1867 +0,0 @@
1
- // biome-ignore-all lint: vendored upstream code keeps its own patterns (see the header)
2
- /*
3
- * Ported from Fluid Functionalism — github.com/mickadesign/fluid-functionalism
4
- * `registry/base/slider.tsx` @ b3587bdbd83fc66c2a6aae3817ffb856cb09260b
5
- * MIT License © 2026 Micka Touillaud — fluidfunctionalism.com (notice: LICENSE.fluid-functionalism)
6
- * Local modifications:
7
- * - `"use client"` directive dropped (no RSC here).
8
- * - `framer-motion` → `motion/react`; `@/lib/*` imports rewritten to relative
9
- * `../../../lib/*` paths.
10
- * - `var(--color-accent)` → `var(--accent)` (the hover-preview fill of both
11
- * designs; this package's tokens have no `--color-*` aliases).
12
- * - `noUncheckedIndexedAccess` guards: non-null assertions on indexed reads
13
- * of the value / step / pip arrays and the ResizeObserver entry (all
14
- * in-bounds by construction — no behavior change).
15
- * - React 18 ref types: ComfortableSlider's `containerRef` is
16
- * `useRef<HTMLDivElement | null>` and a forwarded object ref is assigned
17
- * through `MutableRefObject` (18's `RefObject.current` is readonly).
18
- * - Uncontrolled support (local addition, Base UI style): the public
19
- * `Slider`'s `value` / `onChange` are optional and a new `defaultValue`
20
- * seeds internal state via the shared `useControllableState` (fallback:
21
- * the lowest of `steps`, else `min`, else 0). With `value` passed it stays
22
- * fully controlled, exactly as upstream. The two engines are unchanged and
23
- * still receive a controlled `value` / `onChange` from the wrapper.
24
- * - `SliderProps` extends `Omit<SliderEngineProps, "value" | "onChange">`
25
- * (was `SliderEngineProps`) and re-declares the FF docs API-table props
26
- * with their descriptions, so modo's parser lists them. The track-styling
27
- * props (`trackClassName`, `trackStyle`, `fillClassName`, `fillStyle`,
28
- * `hideFill`, `thumbColor`, `thumbBorderColor`) are still inherited.
29
- * - `aria-label` (local addition) names the thumbs ahead of `label`, so a
30
- * slider without a visible label prefix still has an accessible name; it
31
- * is no longer spread onto the role-less root div.
32
- * - `orientation="vertical"` (local addition): upright track on the compact engine
33
- * (axis position grows from the bottom edge; pointer math reads clientY), and
34
- * `onValueCommit` (both engines): fires once as a pointer drag ends, and for
35
- * keyboard steps and committed edits through Base UI's `onValueCommitted`.
36
- * - TSDoc with the FF docs page's examples added above `Slider`;
37
- * `export default Slider` added.
38
- * - Styling reads DS tokens (AGENTS.md styling): `text-[Npx]` →
39
- * `text-<role>[-compact]`; inline `fontVariationSettings` → `weight-*`; the
40
- * hex focus-ring fallback → `ring-focus-ring` / `border-focus-ring`;
41
- * literal colors → color tokens.
42
- */
43
-
44
- import { Slider as SliderPrimitive } from '@base-ui/react/slider'
45
- import { AnimatePresence, animate, type MotionValue, motion, useMotionValue, useTransform } from 'motion/react'
46
- import {
47
- type CSSProperties,
48
- forwardRef,
49
- type HTMLAttributes,
50
- useCallback,
51
- useEffect,
52
- useLayoutEffect,
53
- useMemo,
54
- useRef,
55
- useState,
56
- } from 'react'
57
- import { useShape } from '../../../lib/shape-context'
58
- import { type SizeVariant, useSizeVariant } from '../../../lib/size-context'
59
- import { spring } from '../../../lib/springs'
60
- import { useControllableState } from '../../../lib/use-controllable-state'
61
- import { cn } from '../../../lib/utils'
62
-
63
- // ---------------------------------------------------------------------------
64
- // Types
65
- // ---------------------------------------------------------------------------
66
-
67
- type SliderValue = number | [number, number]
68
- type ValuePosition = 'left' | 'right' | 'top' | 'bottom' | 'tooltip'
69
-
70
- /** Props of the compact engine — the feature-rich design (ranges, discrete
71
- * step lists, value display) that renders the ladder's compact step. The
72
- * public <Slider> accepts these plus `variant` and `size`. */
73
- interface SliderEngineProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'defaultValue'> {
74
- value: SliderValue
75
- onChange: (value: SliderValue) => void
76
- min?: number
77
- max?: number
78
- step?: number
79
- /**
80
- * Discrete list of allowed values, e.g. [0.1, 0.5, 0.7, 1.1, 1.3].
81
- *
82
- * When set, the thumb snaps only to these values (positioned proportionally
83
- * along the track) and arrow keys walk the list. `min`/`max` derive from the
84
- * list's extremes and `step` is ignored.
85
- */
86
- steps?: number[]
87
- showSteps?: boolean
88
- showValue?: boolean
89
- valuePosition?: ValuePosition
90
- formatValue?: (v: number) => string
91
- label?: string
92
- disabled?: boolean
93
- trackClassName?: string
94
- trackStyle?: CSSProperties
95
- fillClassName?: string
96
- fillStyle?: CSSProperties
97
- hideFill?: boolean
98
- thumbColor?: string
99
- thumbBorderColor?: string
100
- orientation?: 'horizontal' | 'vertical'
101
- onValueCommit?: (value: SliderValue) => void
102
- }
103
-
104
- // ---------------------------------------------------------------------------
105
- // Constants
106
- // ---------------------------------------------------------------------------
107
-
108
- const THUMB_SIZE = 20
109
- const THUMB_SIZE_REST = 16
110
- const TRACK_BG_HEIGHT = 18
111
- const DOT_SIZE = 4
112
- const PIP_SIZE = 5
113
- // Inset track BG so its rounded-end centers align with thumb centers at min/max
114
- const TRACK_INSET = (THUMB_SIZE - TRACK_BG_HEIGHT) / 2
115
-
116
- // ---------------------------------------------------------------------------
117
- // Helpers
118
- // ---------------------------------------------------------------------------
119
-
120
- function valueToPixel(v: number, min: number, max: number, trackWidth: number): number {
121
- if (max === min) return 0
122
- const usable = trackWidth - THUMB_SIZE
123
- return ((v - min) / (max - min)) * usable
124
- }
125
-
126
- function nearestStepIndex(v: number, steps: number[]): number {
127
- let idx = 0
128
- for (let i = 1; i < steps.length; i++) {
129
- if (Math.abs(steps[i]! - v) < Math.abs(steps[idx]! - v)) idx = i
130
- }
131
- return idx
132
- }
133
-
134
- function pixelToValue(
135
- px: number,
136
- min: number,
137
- max: number,
138
- step: number,
139
- trackWidth: number,
140
- stepValues: number[] | null = null,
141
- ): number {
142
- const usable = trackWidth - THUMB_SIZE
143
- if (usable <= 0) return min
144
- const raw = (px / usable) * (max - min) + min
145
- if (stepValues) return stepValues[nearestStepIndex(raw, stepValues)]!
146
- const snapped = Math.round((raw - min) / step) * step + min
147
- return Math.max(min, Math.min(max, snapped))
148
- }
149
-
150
- function toRadixValue(value: SliderValue): number[] {
151
- return Array.isArray(value) ? value : [value]
152
- }
153
-
154
- // ---------------------------------------------------------------------------
155
- // ValueDisplay (internal)
156
- // ---------------------------------------------------------------------------
157
-
158
- interface ValueDisplayProps {
159
- values: number[]
160
- editingIndex: number | null
161
- onStartEdit: (index: number) => void
162
- onCommitEdit: (index: number, v: number) => void
163
- onCancelEdit: () => void
164
- min: number
165
- max: number
166
- step: number
167
- stepValues: number[] | null
168
- formatValue: (v: number) => string
169
- label?: string
170
- isRange: boolean
171
- isInteracting: boolean
172
- }
173
-
174
- function ValueDisplay({
175
- values,
176
- editingIndex,
177
- onStartEdit,
178
- onCommitEdit,
179
- onCancelEdit,
180
- min,
181
- max,
182
- step,
183
- stepValues,
184
- formatValue,
185
- label,
186
- isRange,
187
- isInteracting,
188
- }: ValueDisplayProps) {
189
- const shape = useShape()
190
- const [inputValue, setInputValue] = useState('')
191
- const inputRef = useRef<HTMLInputElement>(null)
192
-
193
- useEffect(() => {
194
- if (editingIndex !== null) {
195
- setInputValue(String(values[editingIndex]))
196
- requestAnimationFrame(() => inputRef.current?.select())
197
- }
198
- }, [editingIndex])
199
-
200
- const commitEdit = useCallback(
201
- (index: number) => {
202
- const parsed = parseFloat(inputValue)
203
- if (!isNaN(parsed)) {
204
- const clamped = Math.max(min, Math.min(max, parsed))
205
- const snapped = stepValues
206
- ? stepValues[nearestStepIndex(clamped, stepValues)]!
207
- : Math.round((clamped - min) / step) * step + min
208
- onCommitEdit(index, snapped)
209
- } else {
210
- onCancelEdit()
211
- }
212
- },
213
- [inputValue, min, max, step, stepValues, onCommitEdit, onCancelEdit],
214
- )
215
-
216
- const renderValue = (index: number) => {
217
- if (editingIndex === index) {
218
- return (
219
- <span className="inline-grid text-body">
220
- {/* Ghost for layout stability — widest possible value */}
221
- <span className="col-start-1 row-start-1 invisible weight-medium" aria-hidden="true">
222
- {label ? `${label}: ` : ''}
223
- {formatValue(max)}
224
- </span>
225
- <span className="col-start-1 row-start-1 flex items-center gap-1">
226
- {label && <span className="text-muted-foreground">{label}:</span>}
227
- <input
228
- ref={inputRef}
229
- type="number"
230
- value={inputValue}
231
- min={min}
232
- max={max}
233
- step={stepValues ? 'any' : step}
234
- onChange={e => setInputValue(e.target.value)}
235
- onBlur={() => commitEdit(index)}
236
- onKeyDown={e => {
237
- if (e.key === 'Enter') commitEdit(index)
238
- if (e.key === 'Escape') onCancelEdit()
239
- }}
240
- aria-label={`Edit slider value${isRange ? (index === 0 ? ' (start)' : ' (end)') : ''}`}
241
- className={cn(
242
- 'w-[5ch] bg-transparent text-foreground outline-none border-b border-border text-center',
243
- shape.input,
244
- 'weight-medium',
245
- )}
246
- />
247
- </span>
248
- </span>
249
- )
250
- }
251
-
252
- return (
253
- <span className="cursor-text select-none" onClick={() => onStartEdit(index)}>
254
- {formatValue(values[index]!)}
255
- </span>
256
- )
257
- }
258
-
259
- const widestValue = isRange
260
- ? `${label ? `${label}: ` : ''}${formatValue(max)} — ${formatValue(max)}`
261
- : `${label ? `${label}: ` : ''}${formatValue(max)}`
262
-
263
- return (
264
- <span
265
- className={cn(
266
- 'inline-grid shrink-0 text-body leading-none text-muted-foreground transition-[font-variation-settings] duration-100',
267
- 'tabular-nums',
268
- isInteracting ? 'weight-medium' : 'weight-normal',
269
- )}
270
- >
271
- {/* Invisible ghost — reserves width of widest possible value */}
272
- <span className="col-start-1 row-start-1 invisible whitespace-nowrap weight-medium" aria-hidden="true">
273
- {widestValue}
274
- </span>
275
- <span className="col-start-1 row-start-1 whitespace-nowrap">
276
- {label && editingIndex === null && <span className="text-muted-foreground">{label}: </span>}
277
- {isRange ? (
278
- <>
279
- {renderValue(0)}
280
- <span className="mx-1 text-muted-foreground/50">—</span>
281
- {renderValue(1)}
282
- </>
283
- ) : (
284
- renderValue(0)
285
- )}
286
- </span>
287
- </span>
288
- )
289
- }
290
-
291
- // ---------------------------------------------------------------------------
292
- // TooltipValue (internal)
293
- // ---------------------------------------------------------------------------
294
-
295
- interface TooltipValueProps {
296
- value: number
297
- formatValue: (v: number) => string
298
- motionX: MotionValue<number>
299
- vertical?: boolean
300
- }
301
-
302
- function TooltipValue({ value, formatValue, motionX, vertical = false }: TooltipValueProps) {
303
- const shape = useShape()
304
- const tooltipX = useTransform(motionX, x => x + THUMB_SIZE / 2)
305
- const tooltipY = useTransform(motionX, x => -x)
306
- return (
307
- <motion.div
308
- className={cn('absolute pointer-events-none z-20', vertical ? 'translate-y-1/2' : '-translate-x-1/2')}
309
- style={
310
- vertical
311
- ? { y: tooltipY, bottom: THUMB_SIZE / 2, left: `calc(50% + ${THUMB_SIZE}px)` }
312
- : { x: tooltipX, top: -16 }
313
- }
314
- initial={vertical ? { opacity: 0, x: -4 } : { opacity: 0, y: 4 }}
315
- animate={vertical ? { opacity: 1, x: 0 } : { opacity: 1, y: 0 }}
316
- exit={
317
- vertical
318
- ? { opacity: 0, x: -4, transition: spring.fast.exit }
319
- : { opacity: 0, y: 4, transition: spring.fast.exit }
320
- }
321
- transition={spring.fast}
322
- >
323
- <span
324
- className={cn(
325
- 'text-caption text-background tabular-nums whitespace-nowrap bg-foreground px-2 py-1',
326
- shape.bg,
327
- 'weight-medium',
328
- )}
329
- >
330
- {formatValue(value)}
331
- </span>
332
- </motion.div>
333
- )
334
- }
335
-
336
- // ---------------------------------------------------------------------------
337
- // CompactSlider — the compact-step design (formerly the only `Slider`).
338
- // ---------------------------------------------------------------------------
339
-
340
- const CompactSlider = forwardRef<HTMLDivElement, SliderEngineProps>(
341
- (
342
- {
343
- value,
344
- onChange,
345
- min: minProp = 0,
346
- max: maxProp = 100,
347
- step = 1,
348
- steps,
349
- showSteps = false,
350
- showValue = true,
351
- valuePosition: valuePositionProp,
352
- formatValue = String,
353
- label,
354
- disabled = false,
355
- trackClassName,
356
- trackStyle,
357
- fillClassName,
358
- fillStyle,
359
- hideFill = false,
360
- thumbColor,
361
- thumbBorderColor,
362
- orientation = 'horizontal',
363
- onValueCommit,
364
- className,
365
- 'aria-label': ariaLabel,
366
- ...props
367
- },
368
- ref,
369
- ) => {
370
- const vertical = orientation === 'vertical'
371
- const valuePosition = valuePositionProp ?? (vertical ? 'top' : 'left')
372
- const onValueCommitRef = useRef(onValueCommit)
373
- onValueCommitRef.current = onValueCommit
374
- const latestValue = useRef<SliderValue>(value)
375
- const isRange = Array.isArray(value)
376
- const values = toRadixValue(value)
377
- const shape = useShape()
378
-
379
- // Non-uniform step mode: sorted, deduped list of allowed values. Keyed on
380
- // the joined string so inline array literals don't recompute every render.
381
- const stepsKey = steps ? steps.join(',') : ''
382
- const stepValues = useMemo(() => {
383
- if (!stepsKey) return null
384
- const parsed = Array.from(new Set(stepsKey.split(',').map(Number))).sort((a, b) => a - b)
385
- return parsed.length > 1 ? parsed : null
386
- }, [stepsKey])
387
- const min = stepValues ? stepValues[0]! : minProp
388
- const max = stepValues ? stepValues[stepValues.length - 1]! : maxProp
389
-
390
- // --- Refs ---
391
- const trackRef = useRef<HTMLDivElement>(null)
392
- const trackWidthRef = useRef(0)
393
- const dragging = useRef(false)
394
- const activeDragThumb = useRef<number>(0)
395
- const valuesRef = useRef(values)
396
- const minRef = useRef(min)
397
- const maxRef = useRef(max)
398
- valuesRef.current = values
399
- minRef.current = min
400
- maxRef.current = max
401
-
402
- // --- State ---
403
- const [isHovered, setIsHovered] = useState(false)
404
- const [isPressed, setIsPressed] = useState(false)
405
- const [editingIndex, setEditingIndex] = useState<number | null>(null)
406
- const [hoverPreview, setHoverPreview] = useState<{
407
- left: number
408
- width: number
409
- snappedValue: number
410
- cursorX: number
411
- } | null>(null)
412
- const [focusedThumb, setFocusedThumb] = useState<number | null>(null)
413
- const [showHoverTooltip, setShowHoverTooltip] = useState(false)
414
- const hoverDelayRef = useRef<ReturnType<typeof setTimeout> | null>(null)
415
-
416
- // Show hover tooltip after 100ms delay
417
- useEffect(() => {
418
- if (isHovered) {
419
- hoverDelayRef.current = setTimeout(() => setShowHoverTooltip(true), 100)
420
- } else {
421
- if (hoverDelayRef.current) clearTimeout(hoverDelayRef.current)
422
- setShowHoverTooltip(false)
423
- }
424
- return () => {
425
- if (hoverDelayRef.current) clearTimeout(hoverDelayRef.current)
426
- }
427
- }, [isHovered])
428
-
429
- // --- Motion values ---
430
- const motionX0 = useMotionValue(0)
431
- const motionX1 = useMotionValue(0)
432
-
433
- // --- Derived motion values for fill ---
434
- const fillLeft = useTransform(motionX0, x => (isRange ? x + THUMB_SIZE / 2 - TRACK_INSET : 0))
435
- const fillWidthSingle = useTransform(motionX0, x => x + THUMB_SIZE / 2 - TRACK_INSET)
436
- const fillWidthRange = useTransform(
437
- [motionX0, motionX1] as MotionValue<number>[],
438
- ([x0, x1]) => (x1 as number) - (x0 as number),
439
- )
440
- const fillWidth = isRange ? fillWidthRange : fillWidthSingle
441
-
442
- // Vertical: the axis position grows upward from the bottom edge.
443
- const thumbY0 = useTransform(motionX0, x => -x)
444
- const thumbY1 = useTransform(motionX1, x => -x)
445
-
446
- // --- Step dots mask (hides dots on filled side, like SliderComfortable pips) ---
447
- const maskDir = vertical ? 'to top' : 'to right'
448
- const stepDotsMaskSingle = useTransform(motionX0, x => {
449
- const edge = x + THUMB_SIZE / 2
450
- return `linear-gradient(${maskDir}, transparent ${edge}px, black ${edge + 2}px)`
451
- })
452
- const stepDotsMaskRange = useTransform([motionX0, motionX1] as MotionValue<number>[], ([x0, x1]) => {
453
- const left = (x0 as number) + THUMB_SIZE / 2
454
- const right = (x1 as number) + THUMB_SIZE / 2
455
- return `linear-gradient(${maskDir}, black ${left - 2}px, transparent ${left}px, transparent ${right}px, black ${right + 2}px)`
456
- })
457
- const stepDotsMask = isRange ? stepDotsMaskRange : stepDotsMaskSingle
458
-
459
- // Pointer position along the track's axis (from the min end) and the track's length, in layout
460
- // space so ancestor CSS scale doesn't skew them.
461
- const measure = useCallback(
462
- (clientX: number, clientY: number) => {
463
- const trackEl = trackRef.current
464
- if (!trackEl) return null
465
- const rect = trackEl.getBoundingClientRect()
466
- const length = vertical ? trackEl.offsetHeight : trackEl.offsetWidth
467
- const visual = vertical ? rect.height : rect.width
468
- if (length <= 0 || visual <= 0) return null
469
- const scale = visual / length
470
- return { length, pos: (vertical ? rect.bottom - clientY : clientX - rect.left) / scale }
471
- },
472
- [vertical],
473
- )
474
-
475
- // --- Hover preview computation ---
476
- const computeHoverPreview = useCallback(
477
- (cursorX: number, trackWidth: number) => {
478
- // cursorX and trackWidth are in layout space (offsetWidth-relative),
479
- // unaffected by ancestor CSS transforms. THUMB_SIZE / TRACK_INSET are
480
- // also layout-space, so the math below is consistent end-to-end.
481
- const usable = trackWidth - THUMB_SIZE
482
- const rawPx = cursorX - THUMB_SIZE / 2
483
- const clampedPx = Math.max(0, Math.min(usable, rawPx))
484
- const rawVal = usable > 0 ? (clampedPx / usable) * (max - min) + min : min
485
- const snappedVal = stepValues
486
- ? stepValues[nearestStepIndex(rawVal, stepValues)]!
487
- : Math.max(min, Math.min(max, Math.round((rawVal - min) / step) * step + min))
488
- const snappedPercent = max === min ? 0 : (snappedVal - min) / (max - min)
489
- const snappedX = THUMB_SIZE / 2 + snappedPercent * usable
490
-
491
- // Find nearest thumb center
492
- const c0 = motionX0.get() + THUMB_SIZE / 2
493
- const c1 = motionX1.get() + THUMB_SIZE / 2
494
- const nearestIdx = isRange ? (Math.abs(snappedX - c0) <= Math.abs(snappedX - c1) ? 0 : 1) : 0
495
- const nearest = nearestIdx === 0 ? c0 : c1
496
-
497
- // Extend hover bar to track edges at extremes so there's no gap
498
- const edgeX = snappedVal === min ? 0 : snappedVal === max ? trackWidth : snappedX
499
- const left = Math.min(nearest, edgeX)
500
- const width = Math.abs(edgeX - nearest)
501
- setHoverPreview({ left, width, snappedValue: snappedVal, cursorX: snappedX })
502
- },
503
- [min, max, step, stepValues, isRange, motionX0, motionX1],
504
- )
505
-
506
- // --- Initial sync (before paint) ---
507
- const initialSyncDone = useRef(false)
508
- const [ready, setReady] = useState(false)
509
- useLayoutEffect(() => {
510
- const el = trackRef.current
511
- if (!el || initialSyncDone.current) return
512
- const w = vertical ? el.offsetHeight : el.offsetWidth
513
- trackWidthRef.current = w
514
- const px0 = valueToPixel(values[0]!, min, max, w)
515
- motionX0.set(px0)
516
- if (isRange && values[1] !== undefined) {
517
- const px1 = valueToPixel(values[1]!, min, max, w)
518
- motionX1.set(px1)
519
- }
520
- initialSyncDone.current = true
521
- setReady(true)
522
- }, [])
523
-
524
- // --- Track width measurement (resize only) ---
525
- useEffect(() => {
526
- const el = trackRef.current
527
- if (!el) return
528
- const ro = new ResizeObserver(([entry]) => {
529
- const w = vertical ? entry!.contentRect.height : entry!.contentRect.width
530
- trackWidthRef.current = w
531
- if (!dragging.current && initialSyncDone.current) {
532
- const v = valuesRef.current
533
- const mn = minRef.current
534
- const mx = maxRef.current
535
- const px0 = valueToPixel(v[0]!, mn, mx, w)
536
- animate(motionX0, px0, spring.moderate)
537
- if (isRange && v[1] !== undefined) {
538
- const px1 = valueToPixel(v[1]!, mn, mx, w)
539
- animate(motionX1, px1, spring.moderate)
540
- }
541
- }
542
- })
543
- ro.observe(el)
544
- return () => ro.disconnect()
545
- }, [isRange, vertical, motionX0, motionX1])
546
-
547
- // --- Sync motion values on value change (keyboard, programmatic) ---
548
- // Depend on a primitive key rather than the `values` array — its identity
549
- // changes every render (toRadixValue allocates), which would restart the
550
- // animation on unrelated re-renders (hover/tooltip state churn).
551
- const valuesKey = values.join(',')
552
- useEffect(() => {
553
- if (!initialSyncDone.current) return
554
- if (dragging.current) return
555
- const tw = trackWidthRef.current
556
- if (tw <= 0) return
557
- const v = valuesRef.current
558
- const px0 = valueToPixel(v[0]!, min, max, tw)
559
- animate(motionX0, px0, spring.moderate)
560
- if (isRange && v[1] !== undefined) {
561
- const px1 = valueToPixel(v[1]!, min, max, tw)
562
- animate(motionX1, px1, spring.moderate)
563
- }
564
- }, [valuesKey, min, max, isRange, motionX0, motionX1])
565
-
566
- // --- Range crossing prevention ---
567
- const clampForRange = useCallback(
568
- (px: number, thumbIndex: number): number => {
569
- if (!isRange) return px
570
- if (thumbIndex === 0) {
571
- return Math.min(px, motionX1.get() - THUMB_SIZE * 0.5)
572
- }
573
- return Math.max(px, motionX0.get() + THUMB_SIZE * 0.5)
574
- },
575
- [isRange, motionX0, motionX1],
576
- )
577
-
578
- // --- Emit value change ---
579
- const emitChange = useCallback(
580
- (thumbIndex: number, newValue: number) => {
581
- if (isRange) {
582
- const newValues: [number, number] = [...(values as [number, number])]
583
- newValues[thumbIndex] = newValue
584
- latestValue.current = newValues
585
- onChange(newValues)
586
- } else {
587
- latestValue.current = newValue
588
- onChange(newValue)
589
- }
590
- },
591
- [isRange, values, onChange],
592
- )
593
-
594
- // --- Pointer handlers on track ---
595
- const handlePointerDown = useCallback(
596
- (e: React.PointerEvent<HTMLDivElement>) => {
597
- if (disabled) return
598
- if (e.pointerType === 'mouse' && e.button !== 0) return
599
- e.preventDefault()
600
- e.stopPropagation() // Prevent Radix from also handling the drag
601
-
602
- // Normalized to layout space so it matches motionX (which is rendered as a
603
- // CSS-pixel transform), even under ancestor CSS scale.
604
- const m = measure(e.clientX, e.clientY)
605
- if (!m) return
606
- const layoutWidth = m.length
607
- const localX = m.pos - THUMB_SIZE / 2
608
- const clamped = Math.max(0, Math.min(layoutWidth - THUMB_SIZE, localX))
609
-
610
- // Determine which thumb to drag
611
- if (isRange) {
612
- const dist0 = Math.abs(clamped - motionX0.get())
613
- const dist1 = Math.abs(clamped - motionX1.get())
614
- activeDragThumb.current = dist0 <= dist1 ? 0 : 1
615
- } else {
616
- activeDragThumb.current = 0
617
- }
618
-
619
- dragging.current = true
620
- setIsPressed(true)
621
-
622
- const motionX = activeDragThumb.current === 0 ? motionX0 : motionX1
623
-
624
- // Snap to step grid immediately
625
- const snappedValue = pixelToValue(clamped, min, max, step, layoutWidth, stepValues)
626
- const snappedPx = valueToPixel(snappedValue, min, max, layoutWidth)
627
-
628
- // Clamp for range crossing
629
- const finalPx = clampForRange(snappedPx, activeDragThumb.current)
630
- // Spring-animate thumb to clicked position
631
- animate(motionX, finalPx, spring.moderate)
632
-
633
- // Update value
634
- const finalValue = pixelToValue(finalPx, min, max, step, layoutWidth, stepValues)
635
- emitChange(activeDragThumb.current, finalValue)
636
-
637
- ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
638
- },
639
- [disabled, isRange, min, max, step, stepValues, motionX0, motionX1, clampForRange, emitChange, measure],
640
- )
641
-
642
- const handlePointerMove = useCallback(
643
- (e: React.PointerEvent<HTMLDivElement>) => {
644
- if (!dragging.current) return
645
- e.stopPropagation()
646
- const m = measure(e.clientX, e.clientY)
647
- if (!m) return
648
- const layoutWidth = m.length
649
- const localX = m.pos - THUMB_SIZE / 2
650
- const clamped = Math.max(0, Math.min(layoutWidth - THUMB_SIZE, localX))
651
-
652
- const motionX = activeDragThumb.current === 0 ? motionX0 : motionX1
653
-
654
- // Snap to step grid during drag
655
- const snappedValue = pixelToValue(clamped, min, max, step, layoutWidth, stepValues)
656
- const snappedPx = valueToPixel(snappedValue, min, max, layoutWidth)
657
- const finalPx = clampForRange(snappedPx, activeDragThumb.current)
658
- motionX.set(finalPx)
659
-
660
- const finalValue = pixelToValue(finalPx, min, max, step, layoutWidth, stepValues)
661
- emitChange(activeDragThumb.current, finalValue)
662
- },
663
- [min, max, step, stepValues, motionX0, motionX1, clampForRange, emitChange, measure],
664
- )
665
-
666
- const handlePointerUp = useCallback(() => {
667
- if (!dragging.current) return
668
- dragging.current = false
669
- onValueCommitRef.current?.(latestValue.current)
670
- setIsPressed(false)
671
- setHoverPreview(null)
672
-
673
- // Spring settle to final quantized position
674
- const tw = trackWidthRef.current
675
- const motionX = activeDragThumb.current === 0 ? motionX0 : motionX1
676
- const currentPx = motionX.get()
677
- const snapped = pixelToValue(currentPx, min, max, step, tw, stepValues)
678
- const snappedPx = valueToPixel(snapped, min, max, tw)
679
- animate(motionX, snappedPx, spring.moderate)
680
- }, [min, max, step, stepValues, motionX0, motionX1])
681
-
682
- // --- Radix keyboard handler ---
683
- // In steps mode the primitive runs on indices (0..len-1, step 1) so arrow
684
- // keys walk the list; map indices back to actual values on the way out.
685
- const handleRadixChange = useCallback(
686
- (newValues: number[]) => {
687
- if (dragging.current) return
688
- const mapped = stepValues ? newValues.map(i => stepValues[Math.round(i)]!) : newValues
689
- if (isRange) {
690
- onChange(mapped as [number, number])
691
- } else {
692
- onChange(mapped[0]!)
693
- }
694
- },
695
- [isRange, onChange, stepValues],
696
- )
697
-
698
- const handleRadixCommit = useCallback(
699
- (newValues: number[]) => {
700
- const mapped = stepValues ? newValues.map(i => stepValues[Math.round(i)]!) : newValues
701
- onValueCommitRef.current?.(isRange ? (mapped as [number, number]) : mapped[0]!)
702
- },
703
- [isRange, stepValues],
704
- )
705
-
706
- // --- Click-to-edit handlers ---
707
- const handleStartEdit = useCallback((index: number) => {
708
- setEditingIndex(index)
709
- }, [])
710
-
711
- const handleCommitEdit = useCallback(
712
- (index: number, v: number) => {
713
- emitChange(index, v)
714
- onValueCommitRef.current?.(latestValue.current)
715
- setEditingIndex(null)
716
- },
717
- [emitChange],
718
- )
719
-
720
- const handleCancelEdit = useCallback(() => {
721
- setEditingIndex(null)
722
- }, [])
723
-
724
- // --- Step dots ---
725
- const stepDots = useMemo(
726
- () =>
727
- showSteps
728
- ? stepValues
729
- ? stepValues.map(v => ({
730
- value: v,
731
- percent: max === min ? 0 : (v - min) / (max - min),
732
- }))
733
- : Array.from({ length: Math.round((max - min) / step) + 1 }, (_, i) => {
734
- const v = min + i * step
735
- const percent = (v - min) / (max - min)
736
- return { value: v, percent }
737
- })
738
- : [],
739
- [showSteps, min, max, step, stepValues],
740
- )
741
-
742
- // --- Interaction state for tooltip ---
743
- const isInteracting = isHovered || isPressed
744
-
745
- // --- Per-thumb accessible names ---
746
- // aria-label on Root lands on a role-less div and never reaches the
747
- // thumb's input, so each Thumb gets its own label.
748
- // `aria-label` names the thumbs ahead of the visible `label` (local addition).
749
- const thumbAriaLabel = (index: number): string | undefined => {
750
- const name = ariaLabel || label
751
- if (!isRange) return name
752
- if (!name) return index === 0 ? 'Minimum' : 'Maximum'
753
- return index === 0 ? `${name} minimum` : `${name} maximum`
754
- }
755
-
756
- // --- Value display component ---
757
- const valueDisplay = showValue && valuePosition !== 'tooltip' && (
758
- <ValueDisplay
759
- values={values}
760
- editingIndex={editingIndex}
761
- onStartEdit={handleStartEdit}
762
- onCommitEdit={handleCommitEdit}
763
- onCancelEdit={handleCancelEdit}
764
- min={min}
765
- max={max}
766
- step={step}
767
- stepValues={stepValues}
768
- formatValue={formatValue}
769
- label={label}
770
- isRange={isRange}
771
- isInteracting={isInteracting}
772
- />
773
- )
774
-
775
- // --- Render visual thumb (not Radix — purely visual) ---
776
- const renderVisualThumb = (index: number) => {
777
- const motionX = index === 0 ? motionX0 : motionX1
778
- return (
779
- <motion.span
780
- key={`visual-thumb-${index}`}
781
- className="flex items-center justify-center pointer-events-none"
782
- style={
783
- vertical
784
- ? {
785
- width: THUMB_SIZE,
786
- height: THUMB_SIZE,
787
- marginLeft: -THUMB_SIZE / 2,
788
- y: index === 0 ? thumbY0 : thumbY1,
789
- position: 'absolute',
790
- left: '50%',
791
- bottom: 0,
792
- zIndex: 10,
793
- }
794
- : {
795
- width: THUMB_SIZE,
796
- height: THUMB_SIZE,
797
- marginTop: -THUMB_SIZE / 2,
798
- x: motionX,
799
- position: 'absolute',
800
- top: '50%',
801
- left: 0,
802
- zIndex: 10,
803
- }
804
- }
805
- initial={false}
806
- >
807
- <motion.span
808
- className={cn('block rounded-full shadow-thumb', !thumbColor && 'bg-thumb')}
809
- initial={false}
810
- animate={{
811
- width: THUMB_SIZE_REST,
812
- height: THUMB_SIZE_REST,
813
- }}
814
- transition={spring.fast}
815
- style={{
816
- backgroundColor: thumbColor,
817
- border: thumbBorderColor ? `1px solid ${thumbBorderColor}` : undefined,
818
- }}
819
- />
820
- {/* Focus ring */}
821
- <motion.span
822
- className="absolute rounded-full border border-focus-ring pointer-events-none"
823
- initial={false}
824
- animate={{
825
- opacity: focusedThumb === index ? 1 : 0,
826
- width: THUMB_SIZE + 4,
827
- height: THUMB_SIZE + 4,
828
- }}
829
- transition={spring.fast}
830
- />
831
- </motion.span>
832
- )
833
- }
834
-
835
- return (
836
- <div
837
- ref={ref}
838
- className={cn(
839
- 'flex gap-0 select-none touch-none overflow-visible',
840
- vertical ? 'h-40 w-fit items-center' : 'flex-col w-full',
841
- valuePosition === 'left' || valuePosition === 'right'
842
- ? vertical
843
- ? 'flex-row gap-2'
844
- : 'flex-row items-center gap-2 mb-2'
845
- : 'flex-col',
846
- vertical && (valuePosition === 'top' || valuePosition === 'bottom' || valuePosition === 'tooltip') && 'gap-2',
847
- disabled && 'opacity-50 pointer-events-none',
848
- className,
849
- )}
850
- {...props}
851
- >
852
- {/* Top / Left value */}
853
- {(valuePosition === 'top' || valuePosition === 'left') && valueDisplay}
854
-
855
- {/* Track area */}
856
- <div
857
- className={cn(
858
- 'relative overflow-visible',
859
- vertical ? 'min-h-0 min-w-0' : 'flex-1',
860
- vertical && (valuePosition === 'left' || valuePosition === 'right' ? 'h-full' : 'flex-1'),
861
- )}
862
- style={
863
- vertical
864
- ? { width: THUMB_SIZE + 16 }
865
- : {
866
- height:
867
- valuePosition === 'left' || valuePosition === 'right'
868
- ? THUMB_SIZE + 16
869
- : THUMB_SIZE + (valuePosition === 'tooltip' ? 16 : 0),
870
- paddingTop: valuePosition === 'tooltip' ? 16 : 0,
871
- }
872
- }
873
- onPointerEnter={() => setIsHovered(true)}
874
- onPointerLeave={() => {
875
- setIsHovered(false)
876
- setHoverPreview(null)
877
- }}
878
- onMouseMove={e => {
879
- if (dragging.current) return
880
- // Layout space, so the formula's THUMB_SIZE / TRACK_INSET constants (layout px)
881
- // match the cursor's coordinate space, even when an ancestor applies a CSS
882
- // scale transform (e.g. /demo).
883
- const m = measure(e.clientX, e.clientY)
884
- if (!m) return
885
- computeHoverPreview(Math.max(0, Math.min(m.length, m.pos)), m.length)
886
- }}
887
- >
888
- {/* Tooltip values */}
889
- {showValue && valuePosition === 'tooltip' && (
890
- <AnimatePresence>
891
- {isInteracting && (
892
- <TooltipValue
893
- key="tooltip-0"
894
- value={values[0]!}
895
- formatValue={formatValue}
896
- motionX={motionX0}
897
- vertical={vertical}
898
- />
899
- )}
900
- {isInteracting && isRange && values[1] !== undefined && (
901
- <TooltipValue
902
- key="tooltip-1"
903
- value={values[1]}
904
- formatValue={formatValue}
905
- motionX={motionX1}
906
- vertical={vertical}
907
- />
908
- )}
909
- </AnimatePresence>
910
- )}
911
-
912
- {/* Base UI Slider — invisible, provides ARIA + keyboard nav */}
913
- <SliderPrimitive.Root
914
- value={stepValues ? values.map(v => nearestStepIndex(v, stepValues)) : values}
915
- onValueChange={v => handleRadixChange(v as number[])}
916
- onValueCommitted={v => handleRadixCommit(v as number[])}
917
- min={stepValues ? 0 : min}
918
- max={stepValues ? stepValues.length - 1 : max}
919
- step={stepValues ? 1 : step}
920
- orientation={orientation}
921
- disabled={disabled}
922
- className="absolute inset-0 opacity-0 pointer-events-none"
923
- style={vertical ? { width: THUMB_SIZE } : { height: THUMB_SIZE }}
924
- >
925
- <SliderPrimitive.Control className="w-full h-full">
926
- <SliderPrimitive.Track className="w-full h-full">
927
- <SliderPrimitive.Indicator />
928
- </SliderPrimitive.Track>
929
- <SliderPrimitive.Thumb
930
- index={0}
931
- aria-label={thumbAriaLabel(0)}
932
- getAriaValueText={stepValues ? () => formatValue(values[0]!) : undefined}
933
- className="block outline-none"
934
- style={{ width: THUMB_SIZE, height: THUMB_SIZE }}
935
- onFocus={e => {
936
- if ((e.target as HTMLElement).matches(':focus-visible')) setFocusedThumb(0)
937
- }}
938
- onBlur={() => setFocusedThumb(prev => (prev === 0 ? null : prev))}
939
- />
940
- {isRange && (
941
- <SliderPrimitive.Thumb
942
- index={1}
943
- aria-label={thumbAriaLabel(1)}
944
- getAriaValueText={stepValues ? () => formatValue(values[1]!) : undefined}
945
- className="block outline-none"
946
- style={{ width: THUMB_SIZE, height: THUMB_SIZE }}
947
- onFocus={e => {
948
- if ((e.target as HTMLElement).matches(':focus-visible')) setFocusedThumb(1)
949
- }}
950
- onBlur={() => setFocusedThumb(prev => (prev === 1 ? null : prev))}
951
- />
952
- )}
953
- </SliderPrimitive.Control>
954
- </SliderPrimitive.Root>
955
-
956
- {/* Visual track with pointer handlers */}
957
- <div
958
- ref={trackRef}
959
- className={cn('relative', vertical ? 'h-full cursor-ns-resize px-2' : 'w-full cursor-ew-resize py-2')}
960
- style={
961
- vertical
962
- ? { width: THUMB_SIZE + 16, opacity: ready ? 1 : 0 }
963
- : { height: THUMB_SIZE + 16, opacity: ready ? 1 : 0 }
964
- }
965
- onPointerDown={handlePointerDown}
966
- onPointerMove={handlePointerMove}
967
- onPointerUp={handlePointerUp}
968
- onPointerCancel={handlePointerUp}
969
- >
970
- {/* Extended hit area — 8px beyond each edge */}
971
- <div
972
- className={cn('absolute', vertical ? 'cursor-ns-resize' : 'cursor-ew-resize')}
973
- style={vertical ? { left: 0, right: 0, top: -8, bottom: -8 } : { left: -8, right: -8, top: 0, bottom: 0 }}
974
- onPointerDown={handlePointerDown}
975
- onPointerMove={handlePointerMove}
976
- onPointerUp={handlePointerUp}
977
- onPointerCancel={handlePointerUp}
978
- />
979
- {/* Hover value tooltip */}
980
- <AnimatePresence>
981
- {hoverPreview && showHoverTooltip && !isPressed && valuePosition !== 'tooltip' && (
982
- <motion.div
983
- key="hover-tooltip"
984
- className={cn('absolute pointer-events-none z-20', vertical ? 'translate-y-1/2' : '-translate-x-1/2')}
985
- initial={vertical ? { opacity: 0, x: -4 } : { opacity: 0, y: 4 }}
986
- animate={vertical ? { opacity: 1, x: 0 } : { opacity: 1, y: 0 }}
987
- exit={
988
- vertical
989
- ? { opacity: 0, x: -4, transition: spring.fast.exit }
990
- : { opacity: 0, y: 4, transition: spring.fast.exit }
991
- }
992
- transition={spring.fast}
993
- style={
994
- vertical
995
- ? { bottom: hoverPreview.cursorX, left: `calc(100% + 4px)` }
996
- : { left: hoverPreview.cursorX, top: -20 }
997
- }
998
- >
999
- <span
1000
- className={cn(
1001
- 'text-caption text-background tabular-nums whitespace-nowrap bg-foreground px-2 py-1',
1002
- shape.bg,
1003
- 'weight-medium',
1004
- )}
1005
- >
1006
- {formatValue(hoverPreview.snappedValue)}
1007
- </span>
1008
- </motion.div>
1009
- )}
1010
- </AnimatePresence>
1011
-
1012
- {/* Track background */}
1013
- <motion.div
1014
- className={cn(
1015
- 'absolute bg-transparent border border-border overflow-hidden rounded-full',
1016
- trackClassName,
1017
- )}
1018
- initial={false}
1019
- animate={
1020
- vertical
1021
- ? { width: TRACK_BG_HEIGHT, left: 8 + (THUMB_SIZE - TRACK_BG_HEIGHT) / 2 }
1022
- : { height: TRACK_BG_HEIGHT, top: 8 + (THUMB_SIZE - TRACK_BG_HEIGHT) / 2 }
1023
- }
1024
- transition={spring.fast}
1025
- style={{
1026
- ...(vertical ? { top: TRACK_INSET, bottom: TRACK_INSET } : { left: TRACK_INSET, right: TRACK_INSET }),
1027
- ...trackStyle,
1028
- }}
1029
- >
1030
- {/* Filled range */}
1031
- {!hideFill && (
1032
- <motion.div
1033
- className={cn(
1034
- 'absolute bg-selected/50 dark:bg-accent/40',
1035
- vertical ? 'w-full' : 'h-full',
1036
- fillClassName,
1037
- )}
1038
- style={{
1039
- ...(vertical ? { bottom: fillLeft, height: fillWidth } : { left: fillLeft, width: fillWidth }),
1040
- ...fillStyle,
1041
- }}
1042
- />
1043
- )}
1044
-
1045
- {/* Hover preview */}
1046
- <motion.div
1047
- className={cn('absolute bg-accent/40 pointer-events-none z-[2]', vertical ? 'w-full' : 'h-full')}
1048
- initial={false}
1049
- animate={{
1050
- opacity: hoverPreview && !isPressed ? 1 : 0,
1051
- }}
1052
- transition={{
1053
- opacity: { duration: 0.15 },
1054
- }}
1055
- style={{
1056
- ...(vertical
1057
- ? {
1058
- bottom: hoverPreview ? hoverPreview.left - TRACK_INSET : 0,
1059
- height: hoverPreview ? hoverPreview.width : 0,
1060
- }
1061
- : {
1062
- left: hoverPreview ? hoverPreview.left - TRACK_INSET : 0,
1063
- width: hoverPreview ? hoverPreview.width : 0,
1064
- }),
1065
- borderRadius:
1066
- hoverPreview && hoverPreview.cursorX > hoverPreview.left
1067
- ? vertical
1068
- ? '9999px 9999px 0 0'
1069
- : '0 9999px 9999px 0'
1070
- : vertical
1071
- ? '0 0 9999px 9999px'
1072
- : '9999px 0 0 9999px',
1073
- }}
1074
- />
1075
- </motion.div>
1076
-
1077
- {/* Step dots — masked so filled side is hidden */}
1078
- {stepDots.length > 0 && (
1079
- <motion.div
1080
- className={cn('absolute pointer-events-none', vertical ? 'top-0 bottom-0' : 'left-0 right-0')}
1081
- style={{
1082
- ...(vertical
1083
- ? { left: 8 + (THUMB_SIZE - TRACK_BG_HEIGHT) / 2, width: TRACK_BG_HEIGHT }
1084
- : { top: 8 + (THUMB_SIZE - TRACK_BG_HEIGHT) / 2, height: TRACK_BG_HEIGHT }),
1085
- WebkitMaskImage: stepDotsMask,
1086
- maskImage: stepDotsMask,
1087
- }}
1088
- >
1089
- {stepDots.map(({ value: v, percent }) => (
1090
- <div
1091
- key={v}
1092
- className="absolute pointer-events-none flex items-center justify-center"
1093
- style={{
1094
- ...(vertical
1095
- ? {
1096
- bottom: `calc(${THUMB_SIZE / 2}px + ${percent} * (100% - ${THUMB_SIZE}px))`,
1097
- left: '50%',
1098
- }
1099
- : {
1100
- left: `calc(${THUMB_SIZE / 2}px + ${percent} * (100% - ${THUMB_SIZE}px))`,
1101
- top: '50%',
1102
- }),
1103
- width: 0,
1104
- height: 0,
1105
- }}
1106
- >
1107
- <motion.div
1108
- className="rounded-full flex-shrink-0 bg-muted-foreground opacity-30"
1109
- initial={false}
1110
- animate={{
1111
- width: isHovered ? DOT_SIZE * 1.25 : DOT_SIZE,
1112
- height: isHovered ? DOT_SIZE * 1.25 : DOT_SIZE,
1113
- }}
1114
- transition={spring.moderate}
1115
- />
1116
- </div>
1117
- ))}
1118
- </motion.div>
1119
- )}
1120
-
1121
- {/* Visual thumbs */}
1122
- {renderVisualThumb(0)}
1123
- {isRange && renderVisualThumb(1)}
1124
- </div>
1125
- </div>
1126
-
1127
- {/* Bottom / Right value */}
1128
- {(valuePosition === 'bottom' || valuePosition === 'right') && valueDisplay}
1129
- </div>
1130
- )
1131
- },
1132
- )
1133
-
1134
- CompactSlider.displayName = 'SliderCompact'
1135
-
1136
- // ---------------------------------------------------------------------------
1137
- // ComfortableSlider — the default-step design (pips / scrubber layouts).
1138
- // ---------------------------------------------------------------------------
1139
-
1140
- interface SliderComfortableProps
1141
- extends Omit<
1142
- HTMLAttributes<HTMLDivElement>,
1143
- 'onChange' | 'defaultValue' | 'onDrag' | 'onDragStart' | 'onDragEnd' | 'onDragOver' | 'onAnimationStart'
1144
- > {
1145
- value: number
1146
- onChange: (value: number) => void
1147
- min?: number
1148
- max?: number
1149
- step?: number
1150
- variant?: 'pips' | 'scrubber'
1151
- label?: string
1152
- formatValue?: (v: number) => string
1153
- disabled?: boolean
1154
- onValueCommit?: (value: number) => void
1155
- }
1156
-
1157
- const ComfortableSlider = forwardRef<HTMLDivElement, SliderComfortableProps>(
1158
- (
1159
- {
1160
- value,
1161
- onChange,
1162
- min = 0,
1163
- max = 100,
1164
- step = 1,
1165
- variant = 'pips',
1166
- label,
1167
- formatValue = String,
1168
- disabled = false,
1169
- onValueCommit,
1170
- className,
1171
- 'aria-label': ariaLabel,
1172
- ...props
1173
- },
1174
- ref,
1175
- ) => {
1176
- const containerRef = useRef<HTMLDivElement | null>(null)
1177
- const onValueCommitRef = useRef(onValueCommit)
1178
- onValueCommitRef.current = onValueCommit
1179
- const latestValue = useRef(value)
1180
- const dragging = useRef(false)
1181
- const handleDragging = useRef(false)
1182
- const [isHovered, setIsHovered] = useState(false)
1183
- const [isPressed, setIsPressed] = useState(false)
1184
- const [isFocused, setIsFocused] = useState(false)
1185
- const [hoverPreview, setHoverPreview] = useState<{
1186
- left: number
1187
- width: number
1188
- snappedValue: number
1189
- cursorX: number
1190
- } | null>(null)
1191
- const [showHoverTooltip, setShowHoverTooltip] = useState(false)
1192
- const hoverDelayRef = useRef<ReturnType<typeof setTimeout> | null>(null)
1193
- const shape = useShape()
1194
-
1195
- // Show hover tooltip after 100ms delay
1196
- useEffect(() => {
1197
- if (isHovered) {
1198
- hoverDelayRef.current = setTimeout(() => setShowHoverTooltip(true), 100)
1199
- } else {
1200
- if (hoverDelayRef.current) clearTimeout(hoverDelayRef.current)
1201
- setShowHoverTooltip(false)
1202
- }
1203
- return () => {
1204
- if (hoverDelayRef.current) clearTimeout(hoverDelayRef.current)
1205
- }
1206
- }, [isHovered])
1207
-
1208
- const mergedRef = useCallback(
1209
- (el: HTMLDivElement | null) => {
1210
- containerRef.current = el
1211
- if (typeof ref === 'function') (ref as React.RefCallback<HTMLDivElement>)(el)
1212
- else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = el
1213
- },
1214
- [ref],
1215
- )
1216
-
1217
- const pipSteps = useMemo(
1218
- () => Array.from({ length: Math.round((max - min) / step) + 1 }, (_, i) => min + i * step),
1219
- [min, max, step],
1220
- )
1221
- const pipCount = pipSteps.length
1222
-
1223
- // Fill motion value
1224
- const fillPercent = useMotionValue(max === min ? 0 : Math.max(0, Math.min(1, (value - min) / (max - min))))
1225
- // Small offset when value is at min so the handle line stays visible
1226
- const zeroTarget = variant === 'pips' ? 8 : 17
1227
- const zeroOffset = useMotionValue(value === min ? zeroTarget : 0)
1228
-
1229
- const fillWidthStyle = useTransform(fillPercent, p => `${p * 100}%`)
1230
- const handleLeftStyle = useTransform(
1231
- [fillPercent, zeroOffset] as MotionValue<number>[],
1232
- ([p, zo]) => `calc(${(p as number) * 100}% - 8px + ${zo as number}px)`,
1233
- )
1234
- const handleLineLeftStyle = useTransform(
1235
- [fillPercent, zeroOffset] as MotionValue<number>[],
1236
- ([p, zo]) => `calc(${(p as number) * 100}% - 9px + ${zo as number}px)`,
1237
- )
1238
- // Pips-specific: offset by px-3 (12px) padding so fill edge aligns with active pip center
1239
- const pipsFillWidthStyle = useTransform(
1240
- [fillPercent, zeroOffset] as MotionValue<number>[],
1241
- ([p, zo]) => `calc(${(p as number) * 100}% + ${20 - 20 * (p as number) - (zo as number) * 2.5}px)`,
1242
- )
1243
- const pipsHandleLineLeftStyle = useTransform(fillPercent, p => `calc(${p * 100}% + ${11 - 24 * p}px)`)
1244
- const pipsMaskStyle = useTransform([fillPercent, zeroOffset] as MotionValue<number>[], ([p, zo]) => {
1245
- const offset = 20 - 20 * (p as number) - (zo as number) * 2.5
1246
- return `linear-gradient(to right, transparent calc(${(p as number) * 100}% + ${offset}px), black calc(${(p as number) * 100}% + ${offset + 2}px))`
1247
- })
1248
-
1249
- // --- Hover preview computation ---
1250
- const computeHoverPreview = useCallback(
1251
- (clientX: number) => {
1252
- const el = containerRef.current
1253
- if (!el) return
1254
- const rect = el.getBoundingClientRect()
1255
- // Use clientWidth (padding box) — CSS % and absolute left/width are relative to it
1256
- const w = el.clientWidth
1257
- if (w <= 0 || rect.width <= 0) return
1258
- // Normalize cursor to layout space so it matches `w` (layout, padding
1259
- // box). offsetWidth is the layout border-box; the difference vs `w` is
1260
- // the horizontal border contribution split across both sides.
1261
- const scale = rect.width / el.offsetWidth
1262
- const borderLeftLayout = (el.offsetWidth - w) / 2
1263
- const visualX = clientX - rect.left
1264
- const layoutX = visualX / scale - borderLeftLayout
1265
- const clamped = Math.max(0, Math.min(w, layoutX))
1266
-
1267
- // Snap to nearest step value
1268
- let snappedVal: number
1269
- if (variant === 'pips') {
1270
- if (pipCount <= 1) return
1271
- const index = Math.max(0, Math.min(pipCount - 1, Math.round((clamped / w) * (pipCount - 1))))
1272
- snappedVal = pipSteps[index]!
1273
- } else {
1274
- const raw = min + (clamped / w) * (max - min)
1275
- snappedVal = Math.max(min, Math.min(max, Math.round((raw - min) / step) * step + min))
1276
- }
1277
- const snappedPercent = max === min ? 0 : (snappedVal - min) / (max - min)
1278
- const snappedX = snappedPercent * w
1279
-
1280
- // Current handle position — for pips, match the visual fill edge offset
1281
- const currentPercent = fillPercent.get()
1282
- let handleX: number
1283
- if (variant === 'pips') {
1284
- const zo = zeroOffset.get()
1285
- handleX = currentPercent * w + (20 - 20 * currentPercent - zo * 2.5)
1286
- } else {
1287
- handleX = currentPercent * w
1288
- }
1289
-
1290
- // Extend hover bar to container edges at extremes so there's no gap
1291
- const edgeX = snappedVal === min ? 0 : snappedVal === max ? w : snappedX
1292
- const left = Math.min(handleX, edgeX)
1293
- const width = Math.abs(edgeX - handleX)
1294
- setHoverPreview({ left, width, snappedValue: snappedVal, cursorX: snappedX })
1295
- },
1296
- [variant, pipSteps, pipCount, min, max, step, fillPercent, zeroOffset],
1297
- )
1298
-
1299
- // Sync fill on programmatic value change
1300
- useEffect(() => {
1301
- if (dragging.current || handleDragging.current) return
1302
- const percent = max === min ? 0 : Math.max(0, Math.min(1, (value - min) / (max - min)))
1303
- animate(fillPercent, percent, spring.fast)
1304
- animate(zeroOffset, value === min ? zeroTarget : 0, spring.fast)
1305
- }, [value, min, max, variant, fillPercent, zeroOffset, zeroTarget])
1306
-
1307
- const getValueFromX = useCallback(
1308
- (clientX: number) => {
1309
- const rect = containerRef.current?.getBoundingClientRect()
1310
- if (!rect) return min
1311
- const x = clientX - rect.left
1312
- const clamped = Math.max(0, Math.min(rect.width, x))
1313
- if (variant === 'pips') {
1314
- if (pipCount <= 1) return min
1315
- const index = Math.max(0, Math.min(pipCount - 1, Math.round((clamped / rect.width) * (pipCount - 1))))
1316
- return pipSteps[index]!
1317
- }
1318
- const raw = min + (clamped / rect.width) * (max - min)
1319
- const snapped = Math.round((raw - min) / step) * step + min
1320
- return Math.max(min, Math.min(max, snapped))
1321
- },
1322
- [variant, pipSteps, pipCount, min, max, step],
1323
- )
1324
-
1325
- const handlePointerDown = useCallback(
1326
- (e: React.PointerEvent<HTMLDivElement>) => {
1327
- if (disabled) return
1328
- if (e.pointerType === 'mouse' && e.button !== 0) return
1329
- e.preventDefault()
1330
- dragging.current = true
1331
- setIsPressed(true)
1332
- const newVal = getValueFromX(e.clientX)
1333
- latestValue.current = newVal
1334
- onChange(newVal)
1335
- const newPercent = Math.max(0, Math.min(1, (newVal - min) / (max - min)))
1336
- animate(fillPercent, newPercent, spring.fast)
1337
- animate(zeroOffset, newVal === min ? zeroTarget : 0, spring.fast)
1338
- ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
1339
- },
1340
- [disabled, getValueFromX, onChange, fillPercent, zeroOffset, zeroTarget, min, max],
1341
- )
1342
-
1343
- const handlePointerMove = useCallback(
1344
- (e: React.PointerEvent<HTMLDivElement>) => {
1345
- if (!dragging.current) return
1346
- const newVal = getValueFromX(e.clientX)
1347
- latestValue.current = newVal
1348
- onChange(newVal)
1349
- const newPercent = Math.max(0, Math.min(1, (newVal - min) / (max - min)))
1350
- if (variant === 'scrubber') {
1351
- fillPercent.set(newPercent)
1352
- } else {
1353
- animate(fillPercent, newPercent, spring.fast)
1354
- }
1355
- animate(zeroOffset, newVal === min ? zeroTarget : 0, spring.fast)
1356
- },
1357
- [getValueFromX, onChange, variant, fillPercent, zeroOffset, zeroTarget, min, max],
1358
- )
1359
-
1360
- const handlePointerUp = useCallback(() => {
1361
- if (dragging.current) onValueCommitRef.current?.(latestValue.current)
1362
- dragging.current = false
1363
- setIsPressed(false)
1364
- setHoverPreview(null)
1365
- }, [])
1366
-
1367
- // Resize handle drag handlers (direct cursor position)
1368
- const handleResizePointerDown = useCallback(
1369
- (e: React.PointerEvent<HTMLDivElement>) => {
1370
- if (disabled) return
1371
- if (e.pointerType === 'mouse' && e.button !== 0) return
1372
- e.preventDefault()
1373
- e.stopPropagation()
1374
- handleDragging.current = true
1375
- setIsPressed(true)
1376
- const newVal = getValueFromX(e.clientX)
1377
- latestValue.current = newVal
1378
- onChange(newVal)
1379
- fillPercent.set(Math.max(0, Math.min(1, (newVal - min) / (max - min))))
1380
- animate(zeroOffset, newVal === min ? zeroTarget : 0, spring.fast)
1381
- ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
1382
- },
1383
- [disabled, getValueFromX, onChange, fillPercent, zeroOffset, zeroTarget, min, max],
1384
- )
1385
-
1386
- const handleResizePointerMove = useCallback(
1387
- (e: React.PointerEvent<HTMLDivElement>) => {
1388
- if (!handleDragging.current) return
1389
- const newVal = getValueFromX(e.clientX)
1390
- latestValue.current = newVal
1391
- onChange(newVal)
1392
- fillPercent.set(Math.max(0, Math.min(1, (newVal - min) / (max - min))))
1393
- animate(zeroOffset, newVal === min ? zeroTarget : 0, spring.fast)
1394
- },
1395
- [getValueFromX, onChange, fillPercent, zeroOffset, zeroTarget, min, max],
1396
- )
1397
-
1398
- const handleResizePointerUp = useCallback(() => {
1399
- if (handleDragging.current) onValueCommitRef.current?.(latestValue.current)
1400
- handleDragging.current = false
1401
- setIsPressed(false)
1402
- setHoverPreview(null)
1403
- }, [])
1404
-
1405
- const handleRadixChange = useCallback(
1406
- (newValues: number[]) => {
1407
- onChange(newValues[0]!)
1408
- },
1409
- [onChange],
1410
- )
1411
-
1412
- const isActive = isHovered || isFocused
1413
-
1414
- return (
1415
- <div
1416
- className="relative w-full touch-none"
1417
- onPointerEnter={() => {
1418
- if (!disabled) setIsHovered(true)
1419
- }}
1420
- onPointerLeave={() => {
1421
- if (!disabled) {
1422
- setIsHovered(false)
1423
- setHoverPreview(null)
1424
- }
1425
- }}
1426
- onMouseMove={e => {
1427
- if (disabled || dragging.current || handleDragging.current) return
1428
- computeHoverPreview(e.clientX)
1429
- }}
1430
- >
1431
- {/* Extended hit area — 8px beyond each edge */}
1432
- <div
1433
- className="absolute cursor-ew-resize"
1434
- style={{ left: -8, right: -8, top: 0, bottom: 0 }}
1435
- onPointerDown={handlePointerDown}
1436
- onPointerMove={handlePointerMove}
1437
- onPointerUp={handlePointerUp}
1438
- onPointerCancel={handlePointerUp}
1439
- />
1440
- {/* Hover value tooltip — outside overflow-hidden container */}
1441
- <AnimatePresence>
1442
- {hoverPreview && showHoverTooltip && !isPressed && (
1443
- <motion.div
1444
- key="hover-tooltip"
1445
- className="absolute -translate-x-1/2 pointer-events-none z-20"
1446
- initial={{ opacity: 0, y: 4 }}
1447
- animate={{ opacity: 1, y: 0 }}
1448
- exit={{ opacity: 0, y: 4, transition: spring.fast.exit }}
1449
- transition={spring.fast}
1450
- style={{
1451
- left: hoverPreview.cursorX,
1452
- top: -30,
1453
- }}
1454
- >
1455
- <span
1456
- className={cn(
1457
- 'text-caption text-background tabular-nums whitespace-nowrap bg-foreground px-2 py-1',
1458
- shape.bg,
1459
- 'weight-medium',
1460
- )}
1461
- >
1462
- {formatValue(hoverPreview.snappedValue)}
1463
- </span>
1464
- </motion.div>
1465
- )}
1466
- </AnimatePresence>
1467
-
1468
- <motion.div
1469
- ref={mergedRef}
1470
- className={cn(
1471
- 'relative w-full h-8 select-none touch-none border border-border overflow-hidden outline-offset-2',
1472
- variant === 'scrubber' ? 'flex items-center gap-3 px-4 cursor-ew-resize' : 'cursor-ew-resize',
1473
- shape.bg,
1474
- disabled && 'opacity-50 pointer-events-none',
1475
- className,
1476
- )}
1477
- initial={false}
1478
- animate={{
1479
- outline: isFocused ? '1px solid var(--focus-ring)' : '1px solid transparent',
1480
- }}
1481
- transition={spring.fast}
1482
- onPointerDown={handlePointerDown}
1483
- onPointerMove={handlePointerMove}
1484
- onPointerUp={handlePointerUp}
1485
- onPointerCancel={handlePointerUp}
1486
- {...props}
1487
- >
1488
- {/* Invisible Base UI Slider for keyboard nav + a11y */}
1489
- <SliderPrimitive.Root
1490
- value={[value]}
1491
- onValueChange={v => handleRadixChange(v as number[])}
1492
- onValueCommitted={v => onValueCommitRef.current?.((v as number[])[0]!)}
1493
- min={min}
1494
- max={max}
1495
- step={step}
1496
- disabled={disabled}
1497
- className="absolute inset-0 opacity-0 pointer-events-none [&_*]:pointer-events-none"
1498
- >
1499
- <SliderPrimitive.Control className="w-full h-full">
1500
- <SliderPrimitive.Track className="w-full h-full">
1501
- <SliderPrimitive.Indicator />
1502
- </SliderPrimitive.Track>
1503
- <SliderPrimitive.Thumb
1504
- index={0}
1505
- aria-label={ariaLabel || label}
1506
- className="block outline-none"
1507
- onFocus={e => {
1508
- if ((e.target as HTMLElement).matches(':focus-visible')) setIsFocused(true)
1509
- }}
1510
- onBlur={() => setIsFocused(false)}
1511
- />
1512
- </SliderPrimitive.Control>
1513
- </SliderPrimitive.Root>
1514
-
1515
- {/* Hover preview */}
1516
- <motion.div
1517
- className="absolute inset-y-0 bg-accent/40 pointer-events-none z-[3]"
1518
- initial={false}
1519
- animate={{
1520
- opacity: hoverPreview && !isPressed ? 1 : 0,
1521
- }}
1522
- transition={{ opacity: { duration: 0.15 } }}
1523
- style={{
1524
- left: hoverPreview ? hoverPreview.left : 0,
1525
- width: hoverPreview ? hoverPreview.width : 0,
1526
- }}
1527
- />
1528
-
1529
- {/* Pips: dots layer — z-[1] */}
1530
- {variant === 'pips' && (
1531
- <motion.div
1532
- className="absolute inset-0 flex justify-between items-center px-3 pointer-events-none z-[1]"
1533
- style={{ WebkitMaskImage: pipsMaskStyle, maskImage: pipsMaskStyle }}
1534
- >
1535
- {pipSteps.map(pipValue => {
1536
- const isActivePip = pipValue === value
1537
- return (
1538
- <div
1539
- key={pipValue}
1540
- className="relative flex items-center justify-center"
1541
- style={{ width: PIP_SIZE, height: PIP_SIZE }}
1542
- >
1543
- <motion.div
1544
- className="rounded-full"
1545
- initial={false}
1546
- animate={{
1547
- backgroundColor: isActivePip ? 'var(--foreground)' : 'var(--muted-foreground)',
1548
- opacity: isActivePip ? 1 : 0.3,
1549
- }}
1550
- transition={spring.fast}
1551
- style={{ width: PIP_SIZE, height: PIP_SIZE }}
1552
- />
1553
- </div>
1554
- )
1555
- })}
1556
- </motion.div>
1557
- )}
1558
-
1559
- {/* Pips: label + value BG layer — z-[2] (occludes dots behind text) */}
1560
- {variant === 'pips' && (
1561
- <div className="absolute inset-0 flex items-center px-2 z-[2] pointer-events-none" aria-hidden>
1562
- {label && <span className="text-body px-2 bg-background text-transparent select-none">{label}</span>}
1563
- <span
1564
- className="text-body tabular-nums ml-auto px-2 bg-background text-transparent select-none"
1565
- style={{ minWidth: `${String(formatValue(max)).length}ch` }}
1566
- >
1567
- {formatValue(value)}
1568
- </span>
1569
- </div>
1570
- )}
1571
-
1572
- {/* Pips: fill — z-[3] */}
1573
- {variant === 'pips' && (
1574
- <motion.div
1575
- className="absolute left-0 top-0 bottom-0 bg-active pointer-events-none z-[3]"
1576
- style={{
1577
- width: pipsFillWidthStyle,
1578
- }}
1579
- />
1580
- )}
1581
-
1582
- {/* Pips: handle line — z-[3] */}
1583
- {variant === 'pips' && (
1584
- <motion.div
1585
- className="absolute rounded-full pointer-events-none z-[3]"
1586
- initial={false}
1587
- animate={{
1588
- top: isActive ? 7 : 8,
1589
- bottom: isActive ? 7 : 8,
1590
- backgroundColor: isFocused
1591
- ? 'var(--foreground)'
1592
- : isHovered
1593
- ? 'color-mix(in srgb, var(--foreground) 50%, transparent)'
1594
- : 'color-mix(in srgb, var(--foreground) 25%, transparent)',
1595
- }}
1596
- transition={spring.fast}
1597
- style={{
1598
- left: pipsHandleLineLeftStyle,
1599
- width: 2,
1600
- }}
1601
- />
1602
- )}
1603
-
1604
- {/* Pips: label + value text layer — z-[4] */}
1605
- {variant === 'pips' && (
1606
- <div className="absolute inset-0 flex items-center px-2 z-[4] pointer-events-none">
1607
- {label && (
1608
- <motion.span
1609
- className="text-body px-2"
1610
- initial={false}
1611
- animate={{ color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)' }}
1612
- transition={spring.fast}
1613
- >
1614
- {label}
1615
- </motion.span>
1616
- )}
1617
- <motion.span
1618
- className="text-body tabular-nums ml-auto px-2"
1619
- initial={false}
1620
- animate={{ color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)' }}
1621
- transition={spring.fast}
1622
- style={{ minWidth: `${String(formatValue(max)).length}ch`, textAlign: 'right' }}
1623
- >
1624
- {formatValue(value)}
1625
- </motion.span>
1626
- </div>
1627
- )}
1628
-
1629
- {/* Scrubber: fill */}
1630
- {variant === 'scrubber' && (
1631
- <motion.div
1632
- className="absolute left-0 top-0 bottom-0 bg-active pointer-events-none"
1633
- style={{
1634
- width: fillWidthStyle,
1635
- }}
1636
- />
1637
- )}
1638
-
1639
- {/* Scrubber: handle line */}
1640
- {variant === 'scrubber' && (
1641
- <motion.div
1642
- className="absolute rounded-full pointer-events-none z-10"
1643
- initial={false}
1644
- animate={{
1645
- top: isActive ? 7 : 8,
1646
- bottom: isActive ? 7 : 8,
1647
- backgroundColor: isFocused
1648
- ? 'var(--foreground)'
1649
- : isHovered
1650
- ? 'color-mix(in srgb, var(--foreground) 50%, transparent)'
1651
- : 'color-mix(in srgb, var(--foreground) 25%, transparent)',
1652
- }}
1653
- transition={spring.fast}
1654
- style={{
1655
- left: handleLineLeftStyle,
1656
- width: 2,
1657
- }}
1658
- />
1659
- )}
1660
-
1661
- {/* Scrubber: label */}
1662
- {variant === 'scrubber' && label && (
1663
- <motion.span
1664
- className="text-body shrink-0 z-10"
1665
- initial={false}
1666
- animate={{ color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)' }}
1667
- transition={spring.fast}
1668
- >
1669
- {label}
1670
- </motion.span>
1671
- )}
1672
-
1673
- {/* Scrubber: flex-1 spacer + value */}
1674
- {variant === 'scrubber' && (
1675
- <>
1676
- <div className="flex-1" />
1677
- <motion.span
1678
- className="text-body shrink-0 tabular-nums text-right z-10"
1679
- initial={false}
1680
- animate={{ color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)' }}
1681
- transition={spring.fast}
1682
- style={{ minWidth: `${String(formatValue(max)).length}ch` }}
1683
- >
1684
- {formatValue(value)}
1685
- </motion.span>
1686
- </>
1687
- )}
1688
-
1689
- {/* Resize handle (scrubber only) */}
1690
- {variant === 'scrubber' && (
1691
- <motion.div
1692
- className="absolute top-0 bottom-0 w-2 cursor-ew-resize z-20"
1693
- style={{ left: handleLeftStyle }}
1694
- onPointerDown={handleResizePointerDown}
1695
- onPointerMove={handleResizePointerMove}
1696
- onPointerUp={handleResizePointerUp}
1697
- onPointerCancel={handleResizePointerUp}
1698
- />
1699
- )}
1700
- </motion.div>
1701
- </div>
1702
- )
1703
- },
1704
- )
1705
-
1706
- ComfortableSlider.displayName = 'SliderComfortable'
1707
-
1708
- // ---------------------------------------------------------------------------
1709
- // Slider — the public component, riding the size ladder.
1710
- //
1711
- // The two historical designs are the two ladder steps: the comfortable
1712
- // pips/scrubber design renders at the default step, the dense original
1713
- // design at the compact step. Features only the compact engine implements
1714
- // (range values, discrete step lists, value display, track styling) force
1715
- // it regardless of the resolved step, so no capability is ever lost.
1716
- // ---------------------------------------------------------------------------
1717
-
1718
- interface SliderProps extends Omit<SliderEngineProps, 'value' | 'onChange'> {
1719
- /** Step on the size ladder (see Sizes). Default renders the pip/scrubber design; compact renders the dense design. Defaults to the surrounding SizeProvider, else `"default"`. */
1720
- size?: SizeVariant
1721
- /** Default-step layout. "pips" shows discrete dot indicators; "scrubber" drags anywhere in the row for a continuous value. Ignored when the compact design renders. Defaults to `"pips"`. */
1722
- variant?: 'pips' | 'scrubber'
1723
- /** Current value (controlled). Pass an array to enable range mode with two thumbs. Omit it to let the slider keep its own state, seeded from `defaultValue`. */
1724
- value?: SliderValue
1725
- /** Initial value when uncontrolled. An array starts the slider in range mode. Defaults to the lowest of `steps`, else `min`, else `0`. */
1726
- defaultValue?: SliderValue
1727
- /** Called when the value changes via drag, click, or keyboard. */
1728
- onChange?: (value: SliderValue) => void
1729
- /** Minimum value. Defaults to `0`. */
1730
- min?: number
1731
- /** Maximum value. Defaults to `100`. */
1732
- max?: number
1733
- /** Step increment. Thumb snaps to the nearest step during drag. Defaults to `1`. */
1734
- step?: number
1735
- /** Discrete list of allowed values, e.g. 0.1, 0.5, 0.7, 1.1, 1.3. The thumb snaps only to these values and arrow keys walk the list; min/max derive from the list's extremes and step is ignored. */
1736
- steps?: number[]
1737
- /** Render dot indicators at each step position on the track. Defaults to `false`. */
1738
- showSteps?: boolean
1739
- /** Whether to display the current value label. Defaults to `true`. */
1740
- showValue?: boolean
1741
- /** Position of the value label. "tooltip" shows above the thumb during interaction. Defaults to `"left"`. */
1742
- valuePosition?: ValuePosition
1743
- /** Custom formatter for the value label. Defaults to `String`. */
1744
- formatValue?: (v: number) => string
1745
- /** Accessible label for the slider, also shown as prefix in the value display. */
1746
- label?: string
1747
- /** Accessible name of the thumb(s) when it should differ from `label`, e.g. no visible `label` at all. Defaults to `label`. */
1748
- 'aria-label'?: string
1749
- /** Disables all interaction. Defaults to `false`. */
1750
- disabled?: boolean
1751
- /** Layout axis. `"vertical"` stands the track upright (min at the bottom, max at the top; ArrowUp / ArrowRight raise it, ArrowDown / ArrowLeft lower it) and always renders the compact design, which has no vertical pip or scrubber layout. Give it a height with `className` (`h-48`). `valuePosition` then defaults to `"top"`. Defaults to `"horizontal"`. */
1752
- orientation?: 'horizontal' | 'vertical'
1753
- /** Called once when an interaction ends with the value it settled on: the pointer lifting after a drag or click, a keyboard step, or a committed value edit. `onChange` reports every value on the way. */
1754
- onValueCommit?: (value: SliderValue) => void
1755
- }
1756
-
1757
- /**
1758
- * One slider, two ladder steps: the default step's pip/scrubber design, and
1759
- * the compact step's dense design with track fill, range mode, and value
1760
- * display.
1761
- *
1762
- * One component: the default step renders the pip/scrubber design, the
1763
- * compact step the dense one. Compact-only props (an array value, steps,
1764
- * showSteps, showValue, valuePosition, track styling) always render the
1765
- * compact design so no capability is lost. Either design snaps to the step
1766
- * grid while dragging, previews the value you are about to pick as a filled
1767
- * bar plus a tooltip, and carries an invisible Base UI slider underneath for
1768
- * the ARIA roles and arrow-key navigation. In the dense design the value
1769
- * label is click-to-edit.
1770
- *
1771
- * Works controlled (`value` + `onChange`, exactly as upstream) or
1772
- * uncontrolled (`defaultValue`, with `onChange` reporting each change).
1773
- * Track styling props are inherited from the compact engine:
1774
- * `trackClassName`, `trackStyle`, `fillClassName`, `fillStyle`, `hideFill`,
1775
- * `thumbColor` and `thumbBorderColor` — each one also forces the dense
1776
- * design.
1777
- *
1778
- * @example {@include ./examples.mdx}
1779
- */
1780
- const Slider = forwardRef<HTMLDivElement, SliderProps>(
1781
- ({ size, variant = 'pips', value: valueProp, defaultValue, onChange: onChangeProp, ...rest }, ref) => {
1782
- // Local addition: controlled + uncontrolled, the way Base UI components
1783
- // take it. The engines below stay controlled — they read `props.value`
1784
- // and call `props.onChange` — so this wrapper owns the state when the
1785
- // caller doesn't.
1786
- const [value, onChange] = useControllableState<SliderValue>(
1787
- valueProp,
1788
- defaultValue ?? (rest.steps && rest.steps.length > 0 ? Math.min(...rest.steps) : (rest.min ?? 0)),
1789
- onChangeProp,
1790
- )
1791
- const props: SliderEngineProps = { ...rest, value, onChange }
1792
- const resolved = useSizeVariant(size)
1793
- const needsCompactEngine =
1794
- Array.isArray(props.value) ||
1795
- props.steps !== undefined ||
1796
- props.showSteps !== undefined ||
1797
- props.showValue !== undefined ||
1798
- props.valuePosition !== undefined ||
1799
- props.trackClassName !== undefined ||
1800
- props.trackStyle !== undefined ||
1801
- props.fillClassName !== undefined ||
1802
- props.fillStyle !== undefined ||
1803
- props.hideFill !== undefined ||
1804
- props.thumbColor !== undefined ||
1805
- props.thumbBorderColor !== undefined ||
1806
- props.orientation === 'vertical'
1807
-
1808
- if (resolved === 'compact' || needsCompactEngine) {
1809
- return <CompactSlider ref={ref} {...props} />
1810
- }
1811
-
1812
- const {
1813
- value: singleValue,
1814
- onChange: singleOnChange,
1815
- min,
1816
- max,
1817
- step,
1818
- label,
1819
- formatValue,
1820
- disabled,
1821
- onValueCommit,
1822
- // Compact-engine-only fields — all undefined on this path (any defined
1823
- // one would have routed to the compact engine above).
1824
- steps: _steps,
1825
- showSteps: _showSteps,
1826
- showValue: _showValue,
1827
- valuePosition: _valuePosition,
1828
- trackClassName: _trackClassName,
1829
- trackStyle: _trackStyle,
1830
- fillClassName: _fillClassName,
1831
- fillStyle: _fillStyle,
1832
- hideFill: _hideFill,
1833
- thumbColor: _thumbColor,
1834
- thumbBorderColor: _thumbBorderColor,
1835
- orientation: _orientation,
1836
- ...html
1837
- } = props
1838
-
1839
- return (
1840
- <ComfortableSlider
1841
- ref={ref}
1842
- value={singleValue as number}
1843
- onChange={singleOnChange as (v: number) => void}
1844
- min={min}
1845
- max={max}
1846
- step={step}
1847
- variant={variant}
1848
- label={label}
1849
- formatValue={formatValue}
1850
- disabled={disabled}
1851
- onValueCommit={onValueCommit as ((v: number) => void) | undefined}
1852
- {...(html as Omit<SliderComfortableProps, 'value' | 'onChange'>)}
1853
- />
1854
- )
1855
- },
1856
- )
1857
-
1858
- Slider.displayName = 'Slider'
1859
-
1860
- /** @deprecated The comfortable design is now the ladder's default step —
1861
- * render <Slider> (optionally with `variant`) instead. */
1862
- const SliderComfortable = ComfortableSlider
1863
-
1864
- export type { SliderComfortableProps, SliderProps, SliderValue, ValuePosition }
1865
- export { Slider, SliderComfortable }
1866
-
1867
- export default Slider