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