@justanarthur/modo-ui 0.6.0-canary.9 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (490) hide show
  1. package/README.md +66 -164
  2. package/dist/base.css +4 -141
  3. package/dist/blocks/sidebar-app/index.js +26 -40
  4. package/dist/components/AI/input-message/index.js +16 -23
  5. package/dist/components/AI/thinking-indicator/index.js +6 -9
  6. package/dist/components/accordion/index.d.ts +11 -39
  7. package/dist/components/accordion/index.js +13 -18
  8. package/dist/components/badge/index.d.ts +11 -18
  9. package/dist/components/badge/index.js +7 -12
  10. package/dist/components/button/index.d.ts +17 -53
  11. package/dist/components/button/index.js +6 -11
  12. package/dist/components/card/index.d.ts +9 -32
  13. package/dist/components/card/index.js +11 -17
  14. package/dist/components/copy/index.d.ts +8 -41
  15. package/dist/components/copy/index.js +9 -15
  16. package/dist/components/inputs/checkbox/index.d.ts +9 -28
  17. package/dist/components/inputs/checkbox/index.js +13 -18
  18. package/dist/components/inputs/color-picker/index.d.ts +14 -22
  19. package/dist/components/inputs/color-picker/index.js +18 -26
  20. package/dist/components/inputs/combobox/index.d.ts +20 -85
  21. package/dist/components/inputs/combobox/index.js +18 -27
  22. package/dist/components/inputs/date-time/calendar/index.d.ts +18 -24
  23. package/dist/components/inputs/date-time/calendar/index.js +11 -15
  24. package/dist/components/inputs/date-time/date-picker/index.d.ts +16 -105
  25. package/dist/components/inputs/date-time/date-picker/index.js +25 -35
  26. package/dist/components/inputs/date-time/date-range-picker/index.d.ts +12 -106
  27. package/dist/components/inputs/date-time/date-range-picker/index.js +24 -35
  28. package/dist/components/inputs/date-time/duration-picker/index.d.ts +13 -66
  29. package/dist/components/inputs/date-time/duration-picker/index.js +24 -33
  30. package/dist/components/inputs/date-time/month-picker/index.d.ts +10 -60
  31. package/dist/components/inputs/date-time/month-picker/index.js +23 -32
  32. package/dist/components/inputs/date-time/time-picker/index.d.ts +10 -58
  33. package/dist/components/inputs/date-time/time-picker/index.js +24 -33
  34. package/dist/components/inputs/input/index.d.ts +13 -95
  35. package/dist/components/inputs/input/index.js +9 -18
  36. package/dist/components/inputs/radio/index.d.ts +5 -20
  37. package/dist/components/inputs/radio/index.js +12 -16
  38. package/dist/components/inputs/search/index.d.ts +3 -3
  39. package/dist/components/inputs/search/index.js +18 -26
  40. package/dist/components/inputs/select/index.d.ts +23 -99
  41. package/dist/components/inputs/select/index.js +14 -29
  42. package/dist/components/inputs/slider/index.d.ts +0 -9
  43. package/dist/components/inputs/slider/index.js +5 -8
  44. package/dist/components/inputs/switch/index.d.ts +2 -8
  45. package/dist/components/inputs/switch/index.js +12 -15
  46. package/dist/components/inputs/textarea/index.d.ts +1 -20
  47. package/dist/components/inputs/textarea/index.js +6 -9
  48. package/dist/components/navigation/sidebar/index.d.ts +21 -44
  49. package/dist/components/navigation/sidebar/index.js +31 -42
  50. package/dist/components/navigation/tabs/index.d.ts +2 -8
  51. package/dist/components/navigation/tabs/index.js +10 -15
  52. package/dist/components/navigation/tabs-subtle/index.js +10 -13
  53. package/dist/components/overlays/alert-dialog/index.d.ts +16 -135
  54. package/dist/components/overlays/alert-dialog/index.js +13 -23
  55. package/dist/components/overlays/command/index.d.ts +20 -25
  56. package/dist/components/overlays/command/index.js +17 -25
  57. package/dist/components/overlays/context-menu/index.d.ts +17 -39
  58. package/dist/components/overlays/context-menu/index.js +18 -29
  59. package/dist/components/overlays/dialog/index.d.ts +16 -42
  60. package/dist/components/overlays/dialog/index.js +12 -20
  61. package/dist/components/overlays/dropdown/index.d.ts +34 -304
  62. package/dist/components/overlays/dropdown/index.js +17 -44
  63. package/dist/components/overlays/popover/index.d.ts +12 -38
  64. package/dist/components/overlays/popover/index.js +9 -13
  65. package/dist/components/overlays/preview-card/index.js +8 -12
  66. package/dist/components/overlays/sheet/index.d.ts +8 -25
  67. package/dist/components/overlays/sheet/index.js +13 -19
  68. package/dist/components/overlays/toast/index.d.ts +50 -209
  69. package/dist/components/overlays/toast/index.js +10 -23
  70. package/dist/components/overlays/tooltip/index.d.ts +21 -123
  71. package/dist/components/overlays/tooltip/index.js +6 -16
  72. package/dist/components/table/index.d.ts +8 -63
  73. package/dist/components/table/index.js +9 -20
  74. package/dist/index.d.ts +1048 -2999
  75. package/dist/index.js +82 -231
  76. package/dist/preset.d.ts +0 -22
  77. package/dist/preset.js +2 -24
  78. package/dist/primitives/code/index.js +5 -8
  79. package/dist/primitives/craft/index.js +6 -10
  80. package/dist/primitives/fluid-hover/index.js +8 -10
  81. package/dist/primitives/morph/index.js +10 -16
  82. package/dist/primitives/motion/index.js +8 -11
  83. package/dist/primitives/scroll-area/index.js +3 -5
  84. package/dist/primitives/sizes/index.js +4 -6
  85. package/dist/primitives/surface/index.js +4 -6
  86. package/dist/shared/{components-c7rwx0ap.js → components-0q0sqfh4.js} +49 -27
  87. package/dist/shared/components-0sn7ypjw.js +61 -0
  88. package/dist/shared/{components-vc864c51.js → components-11da9ajg.js} +5 -8
  89. package/dist/shared/components-1434p1mh.js +123 -0
  90. package/dist/shared/{components-c74gf16d.js → components-1z6tdadk.js} +24 -40
  91. package/dist/shared/{components-bae8sbjx.js → components-27nnvrwe.js} +14 -15
  92. package/dist/shared/{components-qh1cvj6p.js → components-2dxkv6zm.js} +7 -8
  93. package/dist/shared/{components-y0kfy2g4.js → components-2ecg8svx.js} +152 -234
  94. package/dist/shared/{components-c5w5zk0s.js → components-2ph6t61s.js} +34 -37
  95. package/dist/shared/{components-4j3t39dd.js → components-39jq9va1.js} +24 -29
  96. package/dist/shared/components-3ky44908.js +9 -0
  97. package/dist/shared/{components-syynwm1r.js → components-3mkepka5.js} +25 -28
  98. package/dist/shared/{components-wgtjt2af.js → components-4700p9kz.js} +22 -24
  99. package/dist/shared/{components-yhgq0qxx.js → components-59efxjd9.js} +12 -13
  100. package/dist/shared/{components-91zw3d9f.js → components-5vgpnxgy.js} +27 -51
  101. package/dist/shared/{components-kz5q11pp.js → components-5xc2j8jz.js} +18 -19
  102. package/dist/shared/{components-zkk3fmw8.js → components-6p8tfcd6.js} +29 -57
  103. package/dist/shared/{components-4nck8acq.js → components-7r3etddy.js} +54 -19
  104. package/dist/shared/{components-svkt577j.js → components-8snj490h.js} +32 -78
  105. package/dist/shared/{components-ctqmge8t.js → components-b0eac83j.js} +30 -9
  106. package/dist/shared/{components-9sttq78t.js → components-b2wyq2cq.js} +59 -41
  107. package/dist/shared/{components-pv1dyrtn.js → components-b3c5et5b.js} +38 -49
  108. package/dist/shared/{components-rrbpmfx4.js → components-c6s6b43g.js} +2 -3
  109. package/dist/shared/{components-pwayrkw9.js → components-chacnk61.js} +19 -19
  110. package/dist/shared/{components-71nbt03m.js → components-cv16mryv.js} +7 -26
  111. package/dist/shared/{components-hgv6tygd.js → components-d6xtjht3.js} +30 -35
  112. package/dist/shared/{components-z8fedth5.js → components-dk0gjr5a.js} +18 -19
  113. package/dist/shared/{components-cet3jpa0.js → components-fzrq6tr3.js} +34 -37
  114. package/dist/shared/{components-cnead5qm.js → components-gc5vtq2f.js} +30 -66
  115. package/dist/shared/components-gf5s6ap9.js +42 -0
  116. package/dist/shared/{components-0h78g3b5.js → components-gz8zf3ec.js} +55 -56
  117. package/dist/shared/{components-mattt5jp.js → components-h2ajmhwn.js} +35 -107
  118. package/dist/shared/{components-m06yq8wk.js → components-hqeq3w9q.js} +23 -38
  119. package/dist/shared/{components-7qx17na9.js → components-hw9yk32z.js} +24 -36
  120. package/dist/shared/components-hyq23phv.js +310 -0
  121. package/dist/shared/{components-9jmjnkqd.js → components-jbbmdr74.js} +22 -41
  122. package/dist/shared/{components-dcc02hs2.js → components-jjw69c4c.js} +4 -7
  123. package/dist/shared/{components-303jf96s.js → components-jthe1pwb.js} +420 -139
  124. package/dist/shared/{components-dtca4s6b.js → components-jwst3vtj.js} +76 -224
  125. package/dist/shared/{components-dc5q749x.js → components-kvr7f9hy.js} +10 -19
  126. package/dist/shared/{components-s8yqk2dc.js → components-kwa6mrf9.js} +11 -46
  127. package/dist/shared/{components-de99ykt7.js → components-men63rwj.js} +2 -3
  128. package/dist/shared/{components-qx27excf.js → components-mjthm1kf.js} +9 -13
  129. package/dist/shared/{components-1e64t8aj.js → components-n42c79bz.js} +22 -23
  130. package/dist/shared/{components-tz1fv35x.js → components-n7mjr357.js} +2 -3
  131. package/dist/shared/components-ng72k97e.js +128 -0
  132. package/dist/shared/{components-w2jqa6hx.js → components-pbmqctfa.js} +65 -169
  133. package/dist/shared/{components-px2zm7dc.js → components-q5xj5c16.js} +8 -9
  134. package/dist/shared/components-raknhppb.js +64 -0
  135. package/dist/shared/{components-2a7hcand.js → components-rr7a6ja9.js} +27 -59
  136. package/dist/shared/{components-005jt2nz.js → components-s083f38z.js} +34 -164
  137. package/dist/shared/components-s5ngg32c.js +267 -0
  138. package/dist/shared/components-sn1rxx1d.js +143 -0
  139. package/dist/shared/{components-r8eqs7gz.js → components-ta7shw28.js} +4 -4
  140. package/dist/shared/{components-9as3rf68.js → components-vc7vq5nz.js} +3 -6
  141. package/dist/shared/{components-ft3zdc79.js → components-vfbhxf5r.js} +5 -6
  142. package/dist/shared/components-vynjtrk6.js +19 -0
  143. package/dist/shared/{components-p0fm0bab.js → components-w1r67ag9.js} +149 -294
  144. package/dist/shared/{components-xd3emmy4.js → components-w90endve.js} +72 -71
  145. package/dist/shared/{components-t5pk5nmc.js → components-wvp1m52g.js} +102 -149
  146. package/dist/shared/components-wy9jpqny.js +146 -0
  147. package/dist/shared/{components-yxmxn2jp.js → components-y9y0g8er.js} +28 -52
  148. package/dist/shared/components-ypp01w3p.js +986 -0
  149. package/dist/shared/{components-czdvj1a8.js → components-zfnmn5d4.js} +36 -82
  150. package/dist/shared/{components-jqpefjsn.js → components-zrkzcayz.js} +6 -54
  151. package/dist/shared/{components-vf45j12c.js → components-zx6b6yby.js} +18 -4
  152. package/dist/styles.css +32 -320
  153. package/dist/tailwind.css +0 -16
  154. package/dist/tokens.css +0 -67
  155. package/package.json +4 -227
  156. package/DESIGN.md +0 -623
  157. package/blocks/sidebar-app/examples.mdx +0 -33
  158. package/blocks/sidebar-app/index.tsx +0 -218
  159. package/blocks/sidebar-app/inset-topbar.tsx +0 -25
  160. package/blocks/sidebar-app/menu-grid.ts +0 -26
  161. package/blocks/sidebar-app/nav-data.ts +0 -36
  162. package/blocks/sidebar-app/search-field.tsx +0 -48
  163. package/blocks/sidebar-app/user-footer.tsx +0 -67
  164. package/blocks/sidebar-app/workspace-header.tsx +0 -129
  165. package/components/AI/input-message/examples.mdx +0 -130
  166. package/components/AI/input-message/file-thumbnail.tsx +0 -173
  167. package/components/AI/input-message/index.tsx +0 -1385
  168. package/components/AI/thinking-indicator/examples.mdx +0 -27
  169. package/components/AI/thinking-indicator/index.tsx +0 -210
  170. package/components/accordion/examples.mdx +0 -233
  171. package/components/accordion/index.tsx +0 -1071
  172. package/components/avatar/examples.mdx +0 -95
  173. package/components/avatar/index.tsx +0 -129
  174. package/components/badge/change-demo.tsx +0 -29
  175. package/components/badge/examples.mdx +0 -124
  176. package/components/badge/index.tsx +0 -225
  177. package/components/badge/variants.ts +0 -73
  178. package/components/button/examples.mdx +0 -168
  179. package/components/button/index.tsx +0 -424
  180. package/components/button/loading-demo.tsx +0 -25
  181. package/components/button/variants.ts +0 -168
  182. package/components/card/examples.mdx +0 -251
  183. package/components/card/index.tsx +0 -1070
  184. package/components/copy/examples.mdx +0 -125
  185. package/components/copy/index.tsx +0 -662
  186. package/components/inputs/checkbox/examples.mdx +0 -87
  187. package/components/inputs/checkbox/index.tsx +0 -611
  188. package/components/inputs/checkbox/select-all-demo.tsx +0 -48
  189. package/components/inputs/color-picker/examples.mdx +0 -111
  190. package/components/inputs/color-picker/index.tsx +0 -1981
  191. package/components/inputs/color-picker/variants.ts +0 -401
  192. package/components/inputs/combobox/examples.mdx +0 -366
  193. package/components/inputs/combobox/index.tsx +0 -1527
  194. package/components/inputs/combobox/remote-demo.tsx +0 -105
  195. package/components/inputs/date-time/calendar/examples.mdx +0 -45
  196. package/components/inputs/date-time/calendar/index.tsx +0 -370
  197. package/components/inputs/date-time/calendar/use-month-window.ts +0 -173
  198. package/components/inputs/date-time/date-picker/examples.mdx +0 -243
  199. package/components/inputs/date-time/date-picker/index.tsx +0 -476
  200. package/components/inputs/date-time/date-picker/masked-date-input.tsx +0 -348
  201. package/components/inputs/date-time/date-range-picker/examples.mdx +0 -93
  202. package/components/inputs/date-time/date-range-picker/index.tsx +0 -525
  203. package/components/inputs/date-time/duration-picker/examples.mdx +0 -40
  204. package/components/inputs/date-time/duration-picker/index.tsx +0 -253
  205. package/components/inputs/date-time/month-picker/examples.mdx +0 -45
  206. package/components/inputs/date-time/month-picker/index.tsx +0 -254
  207. package/components/inputs/date-time/picker-shell.tsx +0 -581
  208. package/components/inputs/date-time/time-columns.tsx +0 -94
  209. package/components/inputs/date-time/time-picker/examples.mdx +0 -57
  210. package/components/inputs/date-time/time-picker/index.tsx +0 -227
  211. package/components/inputs/field/examples.mdx +0 -117
  212. package/components/inputs/field/index.tsx +0 -268
  213. package/components/inputs/file-dropzone/examples.mdx +0 -83
  214. package/components/inputs/file-dropzone/file-row.tsx +0 -201
  215. package/components/inputs/file-dropzone/index.tsx +0 -316
  216. package/components/inputs/file-dropzone/upload-demo.tsx +0 -67
  217. package/components/inputs/file-dropzone/variants.ts +0 -94
  218. package/components/inputs/input/examples.mdx +0 -166
  219. package/components/inputs/input/index.tsx +0 -494
  220. package/components/inputs/input/password-demo.tsx +0 -30
  221. package/components/inputs/label/examples.mdx +0 -67
  222. package/components/inputs/label/index.tsx +0 -85
  223. package/components/inputs/number-input/examples.mdx +0 -69
  224. package/components/inputs/number-input/index.tsx +0 -221
  225. package/components/inputs/radio/examples.mdx +0 -77
  226. package/components/inputs/radio/index.tsx +0 -636
  227. package/components/inputs/search/examples.mdx +0 -74
  228. package/components/inputs/search/index.tsx +0 -333
  229. package/components/inputs/select/examples.mdx +0 -296
  230. package/components/inputs/select/index.tsx +0 -1044
  231. package/components/inputs/select/variants.ts +0 -32
  232. package/components/inputs/slider/examples.mdx +0 -153
  233. package/components/inputs/slider/index.tsx +0 -1867
  234. package/components/inputs/switch/examples.mdx +0 -96
  235. package/components/inputs/switch/index.tsx +0 -504
  236. package/components/inputs/textarea/examples.mdx +0 -126
  237. package/components/inputs/textarea/index.tsx +0 -207
  238. package/components/navigation/navigation-menu/examples.mdx +0 -136
  239. package/components/navigation/navigation-menu/index.tsx +0 -704
  240. package/components/navigation/pagination/examples.mdx +0 -74
  241. package/components/navigation/pagination/index.tsx +0 -401
  242. package/components/navigation/sidebar/examples.mdx +0 -325
  243. package/components/navigation/sidebar/index.tsx +0 -453
  244. package/components/navigation/sidebar/sidebar-core.tsx +0 -1408
  245. package/components/navigation/sidebar/sidebar-menu.tsx +0 -1495
  246. package/components/navigation/sidebar/sidebar.css +0 -74
  247. package/components/navigation/sidebar/variants.ts +0 -55
  248. package/components/navigation/tabs/examples.mdx +0 -89
  249. package/components/navigation/tabs/index.tsx +0 -494
  250. package/components/navigation/tabs-subtle/examples.mdx +0 -49
  251. package/components/navigation/tabs-subtle/index.tsx +0 -512
  252. package/components/overlays/alert-dialog/examples.mdx +0 -123
  253. package/components/overlays/alert-dialog/index.tsx +0 -276
  254. package/components/overlays/alert-dialog/pending-demo.tsx +0 -36
  255. package/components/overlays/command/examples.mdx +0 -113
  256. package/components/overlays/command/index.tsx +0 -425
  257. package/components/overlays/context-menu/examples.mdx +0 -56
  258. package/components/overlays/context-menu/index.tsx +0 -279
  259. package/components/overlays/dialog/examples.mdx +0 -165
  260. package/components/overlays/dialog/index.tsx +0 -451
  261. package/components/overlays/dropdown/dropdown-search.tsx +0 -395
  262. package/components/overlays/dropdown/examples.mdx +0 -444
  263. package/components/overlays/dropdown/index.tsx +0 -1241
  264. package/components/overlays/dropdown/menu-item.tsx +0 -510
  265. package/components/overlays/dropdown/menu-parts.tsx +0 -725
  266. package/components/overlays/popover/anchored-demo.tsx +0 -59
  267. package/components/overlays/popover/examples.mdx +0 -203
  268. package/components/overlays/popover/index.tsx +0 -317
  269. package/components/overlays/preview-card/examples.mdx +0 -61
  270. package/components/overlays/preview-card/index.tsx +0 -204
  271. package/components/overlays/sheet/examples.mdx +0 -101
  272. package/components/overlays/sheet/index.tsx +0 -307
  273. package/components/overlays/toast/examples.mdx +0 -85
  274. package/components/overlays/toast/global-demo.tsx +0 -72
  275. package/components/overlays/toast/index.tsx +0 -544
  276. package/components/overlays/toast/manager.ts +0 -193
  277. package/components/overlays/tooltip/examples.mdx +0 -106
  278. package/components/overlays/tooltip/index.tsx +0 -539
  279. package/components/progress/examples.mdx +0 -59
  280. package/components/progress/index.tsx +0 -174
  281. package/components/progress/progress.css +0 -49
  282. package/components/progress/step-demo.tsx +0 -27
  283. package/components/table/examples.mdx +0 -188
  284. package/components/table/index.tsx +0 -535
  285. package/components/table/sort-demo.tsx +0 -61
  286. package/dist/components/avatar/index.d.ts +0 -46
  287. package/dist/components/avatar/index.js +0 -19
  288. package/dist/components/badge/variants.d.ts +0 -33
  289. package/dist/components/badge/variants.js +0 -10
  290. package/dist/components/button/variants.d.ts +0 -39
  291. package/dist/components/button/variants.js +0 -8
  292. package/dist/components/inputs/color-picker/variants.d.ts +0 -61
  293. package/dist/components/inputs/color-picker/variants.js +0 -28
  294. package/dist/components/inputs/field/index.d.ts +0 -116
  295. package/dist/components/inputs/field/index.js +0 -29
  296. package/dist/components/inputs/file-dropzone/index.d.ts +0 -167
  297. package/dist/components/inputs/file-dropzone/index.js +0 -33
  298. package/dist/components/inputs/file-dropzone/variants.d.ts +0 -35
  299. package/dist/components/inputs/file-dropzone/variants.js +0 -12
  300. package/dist/components/inputs/label/index.d.ts +0 -32
  301. package/dist/components/inputs/label/index.js +0 -11
  302. package/dist/components/inputs/number-input/index.d.ts +0 -87
  303. package/dist/components/inputs/number-input/index.js +0 -21
  304. package/dist/components/inputs/select/variants.d.ts +0 -5
  305. package/dist/components/inputs/select/variants.js +0 -6
  306. package/dist/components/navigation/navigation-menu/index.d.ts +0 -126
  307. package/dist/components/navigation/navigation-menu/index.js +0 -41
  308. package/dist/components/navigation/pagination/index.d.ts +0 -75
  309. package/dist/components/navigation/pagination/index.js +0 -41
  310. package/dist/components/navigation/sidebar/variants.d.ts +0 -25
  311. package/dist/components/navigation/sidebar/variants.js +0 -28
  312. package/dist/components/progress/index.d.ts +0 -56
  313. package/dist/components/progress/index.js +0 -15
  314. package/dist/docs/index.d.ts +0 -83
  315. package/dist/docs/index.js +0 -288
  316. package/dist/docs/vite.d.ts +0 -31
  317. package/dist/docs/vite.js +0 -45
  318. package/dist/lib/date-locale.d.ts +0 -26
  319. package/dist/lib/date-locale.js +0 -24
  320. package/dist/lib/field-classes.d.ts +0 -149
  321. package/dist/lib/field-classes.js +0 -41
  322. package/dist/lib/field-variants.d.ts +0 -47
  323. package/dist/lib/field-variants.js +0 -26
  324. package/dist/lib/fluid-hover-highlight.d.ts +0 -112
  325. package/dist/lib/fluid-hover-highlight.js +0 -17
  326. package/dist/lib/focus-ring.d.ts +0 -18
  327. package/dist/lib/focus-ring.js +0 -11
  328. package/dist/lib/goo-indicator.d.ts +0 -46
  329. package/dist/lib/goo-indicator.js +0 -14
  330. package/dist/lib/icon-context.d.ts +0 -30
  331. package/dist/lib/icon-context.js +0 -12
  332. package/dist/lib/inert.d.ts +0 -9
  333. package/dist/lib/inert.js +0 -8
  334. package/dist/lib/morph-layers.d.ts +0 -93
  335. package/dist/lib/morph-layers.js +0 -13
  336. package/dist/lib/morph-part.d.ts +0 -21
  337. package/dist/lib/morph-part.js +0 -12
  338. package/dist/lib/popup.d.ts +0 -20
  339. package/dist/lib/popup.js +0 -12
  340. package/dist/lib/reduced-motion.d.ts +0 -9
  341. package/dist/lib/reduced-motion.js +0 -6
  342. package/dist/lib/row-index.d.ts +0 -10
  343. package/dist/lib/row-index.js +0 -8
  344. package/dist/lib/row-order.d.ts +0 -28
  345. package/dist/lib/row-order.js +0 -10
  346. package/dist/lib/scroll-column.d.ts +0 -31
  347. package/dist/lib/scroll-column.js +0 -19
  348. package/dist/lib/shape-context.d.ts +0 -30
  349. package/dist/lib/shape-context.js +0 -13
  350. package/dist/lib/shape-radius.d.ts +0 -19
  351. package/dist/lib/shape-radius.js +0 -6
  352. package/dist/lib/size-context.d.ts +0 -94
  353. package/dist/lib/size-context.js +0 -20
  354. package/dist/lib/size-scale.d.ts +0 -79
  355. package/dist/lib/size-scale.js +0 -8
  356. package/dist/lib/slot.d.ts +0 -39
  357. package/dist/lib/slot.js +0 -14
  358. package/dist/lib/springs.d.ts +0 -44
  359. package/dist/lib/springs.js +0 -8
  360. package/dist/lib/surface-classes.d.ts +0 -19
  361. package/dist/lib/surface-classes.js +0 -10
  362. package/dist/lib/surface-context.d.ts +0 -9
  363. package/dist/lib/surface-context.js +0 -8
  364. package/dist/lib/use-controllable-state.d.ts +0 -2
  365. package/dist/lib/use-controllable-state.js +0 -6
  366. package/dist/lib/use-fluid-hover.d.ts +0 -138
  367. package/dist/lib/use-fluid-hover.js +0 -14
  368. package/dist/lib/use-is-mobile.d.ts +0 -2
  369. package/dist/lib/use-is-mobile.js +0 -6
  370. package/dist/lib/use-keyboard-nav-gate.d.ts +0 -20
  371. package/dist/lib/use-keyboard-nav-gate.js +0 -7
  372. package/dist/lib/use-merge-split.d.ts +0 -47
  373. package/dist/lib/use-merge-split.js +0 -15
  374. package/dist/lib/use-morph.d.ts +0 -101
  375. package/dist/lib/use-morph.js +0 -20
  376. package/dist/lib/use-touch-primary.d.ts +0 -2
  377. package/dist/lib/use-touch-primary.js +0 -6
  378. package/dist/lib/utils.d.ts +0 -3
  379. package/dist/lib/utils.js +0 -6
  380. package/dist/primitives/separator/index.d.ts +0 -25
  381. package/dist/primitives/separator/index.js +0 -9
  382. package/dist/primitives/skeleton/index.d.ts +0 -35
  383. package/dist/primitives/skeleton/index.js +0 -13
  384. package/dist/shared/components-01w9qcfa.js +0 -277
  385. package/dist/shared/components-124epb7f.js +0 -53
  386. package/dist/shared/components-26snnbs9.js +0 -322
  387. package/dist/shared/components-2krsc300.js +0 -254
  388. package/dist/shared/components-2qbqqpgq.js +0 -1545
  389. package/dist/shared/components-34txk9rj.js +0 -35
  390. package/dist/shared/components-3h92d94m.js +0 -23
  391. package/dist/shared/components-4vfm04wq.js +0 -82
  392. package/dist/shared/components-54nq988h.js +0 -123
  393. package/dist/shared/components-7s7pykk5.js +0 -281
  394. package/dist/shared/components-868v7scv.js +0 -399
  395. package/dist/shared/components-8jrk5eye.js +0 -448
  396. package/dist/shared/components-adkarj8n.js +0 -35
  397. package/dist/shared/components-b1e239g3.js +0 -28
  398. package/dist/shared/components-b94t6qtj.js +0 -143
  399. package/dist/shared/components-bsqynhaj.js +0 -179
  400. package/dist/shared/components-c9yr4ed1.js +0 -44
  401. package/dist/shared/components-d3shbwqq.js +0 -95
  402. package/dist/shared/components-dhgk6mbt.js +0 -16
  403. package/dist/shared/components-drxrtnp2.js +0 -25
  404. package/dist/shared/components-f7h57m6t.js +0 -59
  405. package/dist/shared/components-g3eafs70.js +0 -337
  406. package/dist/shared/components-gx4h5rbb.js +0 -40
  407. package/dist/shared/components-gyt7my41.js +0 -328
  408. package/dist/shared/components-h6p86be3.js +0 -19
  409. package/dist/shared/components-h83az0bz.js +0 -33
  410. package/dist/shared/components-hdzrqqpx.js +0 -55
  411. package/dist/shared/components-krtx3m5b.js +0 -84
  412. package/dist/shared/components-kswxnm3h.js +0 -24
  413. package/dist/shared/components-mhza2wdn.js +0 -482
  414. package/dist/shared/components-mrbaxt8q.js +0 -62
  415. package/dist/shared/components-pt5ftm4p.js +0 -38
  416. package/dist/shared/components-rtadhd9j.js +0 -64
  417. package/dist/shared/components-tv7b8tet.js +0 -271
  418. package/dist/shared/components-tz4gaf1v.js +0 -23
  419. package/dist/shared/components-wbbckbw8.js +0 -34
  420. package/dist/shared/components-wknae3m4.js +0 -298
  421. package/dist/shared/components-xdqmt6ed.js +0 -20
  422. package/dist/shared/components-xjkdvnbg.js +0 -107
  423. package/dist/shared/components-z3v0n0de.js +0 -11
  424. package/dist/tokens/colors.css +0 -230
  425. package/dist/tokens/motion.css +0 -40
  426. package/dist/tokens/radius.css +0 -10
  427. package/dist/tokens/spacing.css +0 -9
  428. package/dist/tokens/typography.css +0 -119
  429. package/docs/chrome.css +0 -120
  430. package/docs/chrome.tsx +0 -283
  431. package/docs/index.ts +0 -9
  432. package/docs/vite.ts +0 -85
  433. package/index.ts +0 -60
  434. package/lib/date-locale.ts +0 -195
  435. package/lib/field-classes.ts +0 -121
  436. package/lib/field-variants.ts +0 -117
  437. package/lib/fluid-hover-highlight.tsx +0 -139
  438. package/lib/focus-ring.tsx +0 -46
  439. package/lib/goo-indicator.tsx +0 -261
  440. package/lib/icon-context.tsx +0 -310
  441. package/lib/inert.ts +0 -22
  442. package/lib/morph-layers.tsx +0 -102
  443. package/lib/morph-part.tsx +0 -49
  444. package/lib/popup.ts +0 -52
  445. package/lib/reduced-motion.ts +0 -19
  446. package/lib/row-index.tsx +0 -29
  447. package/lib/row-order.tsx +0 -126
  448. package/lib/scroll-column.tsx +0 -273
  449. package/lib/shape-context.tsx +0 -138
  450. package/lib/shape-radius.ts +0 -67
  451. package/lib/size-context.tsx +0 -66
  452. package/lib/size-scale.ts +0 -115
  453. package/lib/slot.ts +0 -115
  454. package/lib/springs.ts +0 -62
  455. package/lib/surface-classes.ts +0 -58
  456. package/lib/surface-context.tsx +0 -21
  457. package/lib/use-controllable-state.ts +0 -44
  458. package/lib/use-fluid-hover.ts +0 -541
  459. package/lib/use-is-mobile.ts +0 -25
  460. package/lib/use-keyboard-nav-gate.ts +0 -45
  461. package/lib/use-merge-split.tsx +0 -209
  462. package/lib/use-morph.ts +0 -575
  463. package/lib/use-touch-primary.tsx +0 -43
  464. package/lib/utils.ts +0 -37
  465. package/preset.ts +0 -199
  466. package/primitives/code/examples.mdx +0 -27
  467. package/primitives/code/index.tsx +0 -80
  468. package/primitives/craft/demos.tsx +0 -173
  469. package/primitives/craft/examples.mdx +0 -142
  470. package/primitives/craft/index.tsx +0 -76
  471. package/primitives/fluid-hover/examples.mdx +0 -84
  472. package/primitives/fluid-hover/index.tsx +0 -316
  473. package/primitives/morph/examples.mdx +0 -139
  474. package/primitives/morph/index.tsx +0 -192
  475. package/primitives/motion/examples.mdx +0 -100
  476. package/primitives/motion/index.tsx +0 -160
  477. package/primitives/scroll-area/examples.mdx +0 -58
  478. package/primitives/scroll-area/index.tsx +0 -204
  479. package/primitives/separator/examples.mdx +0 -30
  480. package/primitives/separator/index.tsx +0 -53
  481. package/primitives/sizes/examples.mdx +0 -167
  482. package/primitives/sizes/index.tsx +0 -89
  483. package/primitives/skeleton/examples.mdx +0 -51
  484. package/primitives/skeleton/index.tsx +0 -72
  485. package/primitives/skeleton/skeleton.css +0 -15
  486. package/primitives/surface/examples.mdx +0 -173
  487. package/primitives/surface/index.tsx +0 -109
  488. package/primitives/surface/surface.css +0 -176
  489. /package/dist/shared/{components-3ey0hzpb.js → components-1v7rxytx.js} +0 -0
  490. /package/dist/shared/{components-jcnjay5g.js → components-ezxhrmer.js} +0 -0
@@ -1,1981 +0,0 @@
1
- // biome-ignore-all lint: vendored upstream code keeps its own patterns (see the header)
2
- /*
3
- * Ported from Fluid Functionalism — github.com/mickadesign/fluid-functionalism
4
- * `registry/default/color-picker.tsx` @ b3587bdbd83fc66c2a6aae3817ffb856cb09260b
5
- * MIT License © 2026 Micka Touillaud — fluidfunctionalism.com (notice: LICENSE.fluid-functionalism)
6
- * Local modifications:
7
- * - `"use client"` dropped; `@/lib/*`, `@/hooks/*` and
8
- * `@/components/ui/fluid-hover-highlight` → `../../../lib/*` (`SizeProvider`
9
- * → `../../../primitives/sizes`), `@/lib/elevated` → `../../../primitives/surface`,
10
- * `framer-motion` → `motion/react`.
11
- * - `@/registry/radix/{slider,tooltip}` → the Base-flavor siblings `../slider`
12
- * and `../../overlays/tooltip` (same `value`/`onChange`/`trackStyle`/`hideFill`/
13
- * `thumbColor` and `content`/`delayDuration` APIs).
14
- * - `noUncheckedIndexedAccess` guards: regex capture groups and the numeric
15
- * parts of `rgb()`/`hsl()`/`oklch()` read through non-null assertions or a
16
- * local (the alpha part), `itemRects[i]` reads fall back to `null`, and the
17
- * Slider's array value reads `v[0] ?? 0`.
18
- * - Prop JSDoc on `ColorPickerProps` / `ColorPickerPopoverProps` rewritten from
19
- * the FF docs API tables; `className` re-declared so it is documented (modo's
20
- * parser lists only members declared in the interface body).
21
- * - modo item: TSDoc and examples on `ColorPicker`, the compound static
22
- * `ColorPicker.Popover` attached with `Object.assign` and typed through a
23
- * `ColorPickerComponent` cast on the forwardRef, plus a default export.
24
- * Upstream's named exports and exported types are kept.
25
- * - Styling reads DS tokens (AGENTS.md styling): `text-[Npx]` →
26
- * `text-<role>[-compact]`; inline `fontVariationSettings` → `weight-*`; the
27
- * hex focus-ring fallback → `ring-focus-ring` / `border-focus-ring`;
28
- * literal colors → color tokens; `duration-80|120|160` and tier-length JS
29
- * durations → `duration-<tier>` / `spring.*`.
30
- * - The white and black thumb rings, shadows and scrub cursor → `--picker-*`
31
- * tokens. Colors the picker shows (values, swatches, the S/V and hue
32
- * gradients) and the canvas parse sentinels stay literal: they are color
33
- * data, not styling.
34
- * - `ColorPicker.Popover` and the format menu morph out of their triggers
35
- * through the shared morph layer (`lib/use-morph.ts` + `MorphSurface`, goo
36
- * by default on `spring.goo`; the popover takes `from` / `effect` /
37
- * `hideSource` / `tier`, the menu's plain morph is on `spring.moderate`), the origin captured
38
- * from each Base UI root's `onOpenChange`. Their `actionsRef` deferred
39
- * unmounts, fallback timers and motion wrappers are gone: Popover and Menu
40
- * ignore `actionsRef`, and the wrappers animated outside the popup, so Base
41
- * UI unmounted both on the first frame of a close. The popover's panel
42
- * paints no surface of its own (a private `PanelSurfaceContext`): the
43
- * morph's surface layers paint its level; the menu's do the same in place of
44
- * `render={<Elevated/>}`.
45
- * - `effect="morph"` with `hideSource` opens the popover in place over its
46
- * trigger: the positioner's `sideOffset` becomes the engine's `inPlaceOffset`.
47
- * - `ColorPicker.Popover` takes `disabled` / `readOnly` (trigger not tabbable and
48
- * the popover never opens; read-only keeps the plain look), `rootClassName`
49
- * (the div around the trigger) and `removeLabel` (the remove button's accessible name).
50
- * - `ColorSwatch` and `ColorTile` paint their hairline (`shadow-swatch`) on an overlay
51
- * above the color: as the button's own inset shadow it sat under the color
52
- * layer, so a white swatch had no edge on a light surface.
53
- * - The color types and math (`parseColor`, `buildParsed`, conversions) moved
54
- * to `variants.ts` (server-callable, no JSX or hooks) and re-exported here.
55
- */
56
-
57
- import { Menu } from '@base-ui/react/menu'
58
- import { NumberField } from '@base-ui/react/number-field'
59
- import { Popover } from '@base-ui/react/popover'
60
- import { AnimatePresence, motion } from 'motion/react'
61
- import {
62
- type CSSProperties,
63
- createContext,
64
- type ForwardRefExoticComponent,
65
- forwardRef,
66
- type HTMLAttributes,
67
- type ReactNode,
68
- type RefAttributes,
69
- type RefObject,
70
- useCallback,
71
- useContext,
72
- useEffect,
73
- useMemo,
74
- useRef,
75
- useState,
76
- } from 'react'
77
- import { FluidHoverHighlight } from '../../../lib/fluid-hover-highlight'
78
- import { useIcon } from '../../../lib/icon-context'
79
- import { MorphSurface } from '../../../lib/morph-layers'
80
- import { shapeMap, useShape } from '../../../lib/shape-context'
81
- import { type SizeVariant, useSize } from '../../../lib/size-context'
82
- import { spring } from '../../../lib/springs'
83
- import { SURFACE_BG, SURFACE_SHADOW, surfaceClasses } from '../../../lib/surface-classes'
84
- import { SurfaceProvider, useSurface } from '../../../lib/surface-context'
85
- import { useFluidHover, useRegisterFluidHoverItem } from '../../../lib/use-fluid-hover'
86
- import { inPlaceOffset, opensInPlace, useMorph, useMorphOrigin } from '../../../lib/use-morph'
87
- import { cn } from '../../../lib/utils'
88
- import { SizeProvider } from '../../../primitives/sizes'
89
- import { Tooltip } from '../../overlays/tooltip'
90
- import { Slider } from '../slider'
91
- import {
92
- buildParsed,
93
- type ColorFormat,
94
- clamp01,
95
- formatValueByFormat,
96
- hslToRgb,
97
- hsvToRgb,
98
- oklchToRgb,
99
- type ParsedColor,
100
- parseColor,
101
- resolveCssColor,
102
- rgbToHexStr,
103
- rgbToHsl,
104
- rgbToHsv,
105
- rgbToOklch,
106
- } from './variants'
107
-
108
- // ---------------------------------------------------------------------------
109
- // Types
110
- // ---------------------------------------------------------------------------
111
-
112
- // Allows consumers (e.g. the /demo carousel) to portal popups inside a
113
- // CSS-scaled ancestor so menu/popover layers visually scale with the picker.
114
- const ColorPickerPortalContainerContext = createContext<HTMLElement | null>(null)
115
-
116
- function ColorPickerPortalContainer({ value, children }: { value: HTMLElement | null; children: ReactNode }) {
117
- return (
118
- <ColorPickerPortalContainerContext.Provider value={value}>{children}</ColorPickerPortalContainerContext.Provider>
119
- )
120
- }
121
-
122
- // False inside ColorPicker.Popover, whose morph layers paint the panel's
123
- // surface; the panel then paints none of its own.
124
- const PanelSurfaceContext = createContext(true)
125
-
126
- interface ColorPickerProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'defaultValue'> {
127
- /** Controlled color value, in any of the supported formats. Omit it to let the picker keep its own state, seeded from `defaultValue`. */
128
- value?: string
129
- /** Initial color when uncontrolled — any CSS color string, named colors included. Defaults to `"#6B97FF"`. */
130
- defaultValue?: string
131
- /** Fired on every change. Receives the formatted string and a parsed color object carrying every format. */
132
- onValueChange?: (value: string, parsed: ParsedColor) => void
133
- /** Controlled format selection. */
134
- format?: ColorFormat
135
- /** Initial format when uncontrolled. Defaults to `"hex"`. */
136
- defaultFormat?: ColorFormat
137
- /** Fired when the user switches format. */
138
- onFormatChange?: (format: ColorFormat) => void
139
- /** Optional preset swatches. When omitted, the strip is hidden. */
140
- swatches?: string[]
141
- /** Force-hide the eyedropper button — it is hidden automatically in browsers that do not support it. Defaults to `false`. */
142
- hideEyedropper?: boolean
143
- /** Controls the format dropdown's open state. When provided, the dropdown is fully controlled and ignores user toggles. */
144
- formatOpen?: boolean
145
- /** Initial open state for the format dropdown (uncontrolled). Defaults to `false`. */
146
- defaultFormatOpen?: boolean
147
- /** Pins trigger and popover to one step of the size ladder (default 36px, compact 28px — see Sizes). Omitted, they follow the surrounding SizeProvider. */
148
- size?: SizeVariant
149
- /** Merged after the panel's surface, radius and padding classes. */
150
- className?: string
151
- }
152
-
153
- interface ColorPickerPopoverProps extends ColorPickerProps {
154
- /** Optional label rendered alongside the color tile. */
155
- triggerLabel?: string
156
- /** Position of the label relative to the color tile. Defaults to `"left"`. */
157
- triggerLabelPosition?: 'left' | 'right'
158
- /** Show the hex value (without alpha) next to the tile. Defaults to `true`. */
159
- triggerShowValue?: boolean
160
- /** Render an X button on the trigger; pairs with `onTriggerRemove`. Hidden while `disabled` or `readOnly`. Defaults to `false`. */
161
- triggerShowRemove?: boolean
162
- /** Fired when the X button is clicked. */
163
- onTriggerRemove?: () => void
164
- /** Accessible name of the trigger's X button. Defaults to `"Remove color"`. */
165
- removeLabel?: string
166
- /** Disables the trigger: it dims, skips the tab order and never opens the panel. Defaults to `false`. */
167
- disabled?: boolean
168
- /** Shows the color but keeps it unchangeable: the trigger skips the tab order, loses its hover fill, never opens the panel and hides the X button. Defaults to `false`. */
169
- readOnly?: boolean
170
- /** Classes for the div around the trigger, an `inline-flex` box. Add `w-full` with `triggerClassName="w-full"` to make the trigger fill a field's width. `className` styles the panel, not this. */
171
- rootClassName?: string
172
- /** Custom classes on the trigger button. */
173
- triggerClassName?: string
174
- /** Controls the popover's open state. When provided, the popover is fully
175
- * controlled and ignores trigger clicks. */
176
- open?: boolean
177
- /** Initial open state for the popover (uncontrolled). Defaults to `false`. */
178
- defaultOpen?: boolean
179
- /** Called when the open state would change (fires even when controlled). */
180
- onOpenChange?: (open: boolean) => void
181
- /** Where the panel grows from (see Morph): the trigger, the press point, its own center, a viewport edge, or a ref to any element. Defaults to `'trigger'`. */
182
- from?: 'trigger' | 'pointer' | 'center' | 'top' | 'right' | 'bottom' | 'left' | RefObject<HTMLElement | null>
183
- /** How it grows (see Morph): with the liquid goo neck, a plain morph, a slide or a fade. Defaults to `'goo'`. */
184
- effect?: 'goo' | 'morph' | 'slide' | 'fade'
185
- /** Hide the trigger while open, so it reads as turning into the panel. Defaults to `false`. */
186
- hideSource?: boolean
187
- /** Spring tier of a plain morph, slide or fade; goo runs on its own `spring.goo`. Defaults to `'slow'`. */
188
- tier?: 'moderate' | 'slow'
189
- }
190
-
191
- interface ColorSwatchProps extends Omit<HTMLAttributes<HTMLButtonElement>, 'color'> {
192
- /** The swatch color — any CSS color string. */
193
- color: string
194
- /** Edge length in pixels. Defaults to `28`. */
195
- size?: number
196
- /** Draws the selection ring. */
197
- selected?: boolean
198
- }
199
-
200
- // ---------------------------------------------------------------------------
201
- // Constants
202
- // ---------------------------------------------------------------------------
203
-
204
- const PANEL_WIDTH = 280
205
- const SQUARE_HEIGHT = 156
206
- const CHECKER_BG: CSSProperties = {
207
- backgroundImage:
208
- 'conic-gradient(var(--checker-a) 0 25%, var(--checker-b) 0 50%, var(--checker-a) 0 75%, var(--checker-b) 0)',
209
- backgroundSize: '8px 8px',
210
- }
211
-
212
- // ---------------------------------------------------------------------------
213
- // SaturationSquare
214
- // ---------------------------------------------------------------------------
215
-
216
- interface SaturationSquareProps {
217
- h: number
218
- s: number
219
- v: number
220
- onChange: (s: number, v: number) => void
221
- }
222
-
223
- function SaturationSquare({ h, s, v, onChange }: SaturationSquareProps) {
224
- const ref = useRef<HTMLDivElement>(null)
225
- // State (not a ref): this gates the ghost hover cursor during render, and a
226
- // ref mutation wouldn't re-render, letting the ghost stick around.
227
- const [dragging, setDragging] = useState(false)
228
- const hasMoved = useRef(false)
229
- const [focused, setFocused] = useState(false)
230
- const [hovered, setHovered] = useState(false)
231
- const [cursorPos, setCursorPos] = useState<{ x: number; y: number } | null>(null)
232
- const shape = useShape()
233
-
234
- const updateFromPointer = useCallback(
235
- (clientX: number, clientY: number) => {
236
- const rect = ref.current?.getBoundingClientRect()
237
- if (!rect) return
238
- const x = clamp01((clientX - rect.left) / rect.width)
239
- const y = clamp01((clientY - rect.top) / rect.height)
240
- onChange(x, 1 - y)
241
- },
242
- [onChange],
243
- )
244
-
245
- const updateCursorPos = useCallback((clientX: number, clientY: number) => {
246
- const rect = ref.current?.getBoundingClientRect()
247
- if (!rect) return
248
- setCursorPos({
249
- x: clamp01((clientX - rect.left) / rect.width) * 100,
250
- y: clamp01((clientY - rect.top) / rect.height) * 100,
251
- })
252
- }, [])
253
-
254
- const onPointerDown = useCallback(
255
- (e: React.PointerEvent<HTMLDivElement>) => {
256
- if (e.pointerType === 'mouse' && e.button !== 0) return
257
- e.preventDefault()
258
- setDragging(true)
259
- hasMoved.current = false
260
- ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
261
- updateFromPointer(e.clientX, e.clientY)
262
- },
263
- [updateFromPointer],
264
- )
265
-
266
- const onPointerMove = useCallback(
267
- (e: React.PointerEvent<HTMLDivElement>) => {
268
- updateCursorPos(e.clientX, e.clientY)
269
- if (!dragging) return
270
- hasMoved.current = true
271
- updateFromPointer(e.clientX, e.clientY)
272
- },
273
- [dragging, updateFromPointer, updateCursorPos],
274
- )
275
-
276
- const onPointerUp = useCallback(() => {
277
- setDragging(false)
278
- hasMoved.current = false
279
- }, [])
280
-
281
- const onKeyDown = useCallback(
282
- (e: React.KeyboardEvent<HTMLDivElement>) => {
283
- const step = e.shiftKey ? 0.1 : 0.01
284
- let nextS = s,
285
- nextV = v,
286
- handled = true
287
- if (e.key === 'ArrowLeft') nextS = clamp01(s - step)
288
- else if (e.key === 'ArrowRight') nextS = clamp01(s + step)
289
- else if (e.key === 'ArrowUp') nextV = clamp01(v + step)
290
- else if (e.key === 'ArrowDown') nextV = clamp01(v - step)
291
- else handled = false
292
- if (handled) {
293
- e.preventDefault()
294
- onChange(nextS, nextV)
295
- }
296
- },
297
- [onChange, s, v],
298
- )
299
-
300
- const { r, g, b } = hsvToRgb(h, s, v)
301
- const thumbColor = `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`
302
-
303
- return (
304
- <div
305
- ref={ref}
306
- role="application"
307
- aria-label="Saturation and brightness"
308
- tabIndex={0}
309
- onFocus={e => {
310
- if (e.currentTarget.matches(':focus-visible')) setFocused(true)
311
- }}
312
- onBlur={() => setFocused(false)}
313
- onPointerEnter={() => setHovered(true)}
314
- onPointerLeave={() => {
315
- setHovered(false)
316
- setCursorPos(null)
317
- }}
318
- onPointerDown={onPointerDown}
319
- onPointerMove={onPointerMove}
320
- onPointerUp={onPointerUp}
321
- onKeyDown={onKeyDown}
322
- className={cn(
323
- 'relative w-full select-none touch-none cursor-none outline-none',
324
- focused && 'ring-2 ring-focus-ring',
325
- shape.bg,
326
- )}
327
- style={{
328
- height: SQUARE_HEIGHT,
329
- }}
330
- >
331
- <div
332
- className={cn('absolute inset-0 overflow-hidden', shape.bg === 'rounded-[20px]' ? 'rounded-2xl' : shape.bg)}
333
- style={{
334
- background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${h}, 100%, 50%))`,
335
- }}
336
- />
337
- <motion.div
338
- className="absolute pointer-events-none rounded-full border border-thumb shadow-picker-thumb"
339
- initial={false}
340
- animate={{
341
- left: `${s * 100}%`,
342
- top: `${(1 - v) * 100}%`,
343
- width: 18,
344
- height: 18,
345
- }}
346
- transition={{ duration: 0 }}
347
- style={{
348
- transform: 'translate(-50%, -50%)',
349
- backgroundColor: thumbColor,
350
- }}
351
- />
352
- {hovered && !dragging && cursorPos && (
353
- <div
354
- className="absolute pointer-events-none rounded-full border-2 border-picker-ring shadow-picker-cursor"
355
- style={{
356
- left: `${cursorPos.x}%`,
357
- top: `${cursorPos.y}%`,
358
- width: 18,
359
- height: 18,
360
- transform: 'translate(-50%, -50%)',
361
- }}
362
- />
363
- )}
364
- </div>
365
- )
366
- }
367
-
368
- // ---------------------------------------------------------------------------
369
- // HueSlider
370
- // ---------------------------------------------------------------------------
371
-
372
- function HueSlider({ h, onChange }: { h: number; onChange: (h: number) => void }) {
373
- const hueColor = `hsl(${h}, 100%, 50%)`
374
- return (
375
- <Slider
376
- value={h}
377
- onChange={v => onChange(typeof v === 'number' ? v : (v[0] ?? 0))}
378
- min={0}
379
- max={360}
380
- step={1}
381
- showValue={false}
382
- hideFill
383
- thumbColor={hueColor}
384
- thumbBorderColor="var(--picker-thumb-border)"
385
- trackStyle={{
386
- background:
387
- 'linear-gradient(to right, hsl(0,100%,50%), hsl(60,100%,50%), hsl(120,100%,50%), hsl(180,100%,50%), hsl(240,100%,50%), hsl(300,100%,50%), hsl(360,100%,50%))',
388
- borderColor: 'transparent',
389
- }}
390
- aria-label="Hue"
391
- />
392
- )
393
- }
394
-
395
- // ---------------------------------------------------------------------------
396
- // AlphaSlider
397
- // ---------------------------------------------------------------------------
398
-
399
- function AlphaSlider({
400
- a,
401
- solidColor,
402
- solidR,
403
- solidG,
404
- solidB,
405
- onChange,
406
- }: {
407
- a: number
408
- solidColor: string
409
- solidR: number
410
- solidG: number
411
- solidB: number
412
- onChange: (a: number) => void
413
- }) {
414
- // Use color-aware transparent stop (same hue, alpha 0) so the gradient stays
415
- // chromatically consistent and reaches fully opaque at 100% with no edge gap.
416
- const transparentColor = `rgba(${solidR}, ${solidG}, ${solidB}, 0)`
417
- return (
418
- <Slider
419
- value={Math.round(a * 100)}
420
- onChange={v => onChange((typeof v === 'number' ? v : (v[0] ?? 0)) / 100)}
421
- min={0}
422
- max={100}
423
- step={1}
424
- showValue={false}
425
- hideFill
426
- thumbColor={solidColor}
427
- thumbBorderColor="var(--picker-thumb-border)"
428
- trackStyle={{
429
- backgroundImage: `linear-gradient(to right, ${transparentColor} 0%, ${solidColor} 98%), conic-gradient(var(--checker-a) 0 25%, var(--checker-b) 0 50%, var(--checker-a) 0 75%, var(--checker-b) 0)`,
430
- backgroundSize: '100% 100%, 8px 8px',
431
- borderWidth: 0,
432
- }}
433
- aria-label="Alpha"
434
- />
435
- )
436
- }
437
-
438
- // ---------------------------------------------------------------------------
439
- // FormatDropdown
440
- //
441
- // Built on Base UI's Menu primitive, which owns trigger wiring, positioning
442
- // (anchor tracking + collision flipping — the old hand-rolled version computed
443
- // coordinates once on open and detached from the trigger on scroll),
444
- // dismissal, roving highlight, and typeahead. Menu.RadioGroup/RadioItem carry
445
- // the radio semantics. This layer keeps the fluid-hover
446
- // overlays and the morph open/close (lib/use-morph.ts) — the same pattern as
447
- // select.tsx / dropdown.tsx.
448
- // ---------------------------------------------------------------------------
449
-
450
- const FORMAT_LABELS: Record<ColorFormat, string> = {
451
- hex: 'HEX',
452
- rgb: 'RGB',
453
- hsl: 'HSL',
454
- oklch: 'OKLCH',
455
- }
456
-
457
- const FORMATS = ['hex', 'rgb', 'hsl', 'oklch'] as const
458
-
459
- // Popup surfaces opt out of the global pill/rounded shape — same rationale as
460
- // the Dropdown component (pill radii distort perceived padding at this scale).
461
- const menuShape = shapeMap.rounded
462
-
463
- interface FormatMenuContextValue {
464
- registerItem: (index: number, element: HTMLElement | null) => void
465
- activeIndex: number | null
466
- checkedIndex?: number
467
- }
468
-
469
- const FormatMenuContext = createContext<FormatMenuContextValue | null>(null)
470
-
471
- function FormatItem({
472
- index,
473
- value,
474
- label,
475
- checked,
476
- }: {
477
- index: number
478
- value: ColorFormat
479
- label: string
480
- checked: boolean
481
- }) {
482
- const ref = useRef<HTMLDivElement>(null)
483
- const menuCtx = useContext(FormatMenuContext)
484
- const shape = useShape()
485
- const sizeClasses = useSize()
486
- const compact = sizeClasses.variant === 'compact'
487
-
488
- useRegisterFluidHoverItem(menuCtx?.registerItem, index, ref)
489
-
490
- const isActive = menuCtx?.activeIndex === index
491
-
492
- return (
493
- <Menu.RadioItem
494
- value={value}
495
- label={label}
496
- closeOnClick
497
- render={
498
- <div
499
- ref={ref}
500
- data-fluid-hover-index={index}
501
- className={cn(
502
- 'relative z-10 flex items-center cursor-pointer outline-none',
503
- compact ? 'px-2.5 py-1.5' : 'px-3 py-2',
504
- sizeClasses.text,
505
- shape.item,
506
- )}
507
- />
508
- }
509
- >
510
- <span className="inline-grid">
511
- <span className="col-start-1 row-start-1 invisible weight-semibold" aria-hidden="true">
512
- {label}
513
- </span>
514
- <span
515
- className={cn(
516
- 'col-start-1 row-start-1 transition-[color,font-variation-settings] duration-fast',
517
- isActive || checked ? 'text-foreground' : 'text-muted-foreground',
518
- checked ? 'weight-semibold' : 'weight-normal',
519
- )}
520
- >
521
- {label}
522
- </span>
523
- </span>
524
- </Menu.RadioItem>
525
- )
526
- }
527
-
528
- function FormatDropdown({
529
- value,
530
- onChange,
531
- open: openProp,
532
- defaultOpen = false,
533
- }: {
534
- value: ColorFormat
535
- onChange: (f: ColorFormat) => void
536
- open?: boolean
537
- defaultOpen?: boolean
538
- }) {
539
- const [internalOpen, setInternalOpen] = useState(defaultOpen)
540
- const isControlled = openProp !== undefined
541
- const open = isControlled ? openProp : internalOpen
542
- const { origin, capture } = useMorphOrigin()
543
- const morph = useMorph(open, origin, { tier: 'moderate' })
544
- // Lifts 2 levels off the panel with a fixed shadow (see Elevated).
545
- const level = Math.min(useSurface() + 2, 8)
546
- const shape = useShape()
547
- const sizeClasses = useSize()
548
- const portalContainer = useContext(ColorPickerPortalContainerContext)
549
- const containerRef = useRef<HTMLDivElement>(null)
550
- const ChevronDownIcon = useIcon('chevron-down')
551
-
552
- const hover = useFluidHover(containerRef)
553
- const { activeIndex, setActiveIndex, itemRects, handlers, registerItem, remeasure } = hover
554
-
555
- const [focusedIndex, setFocusedIndex] = useState<number | null>(null)
556
-
557
- // The popup keeps its rows registered between opens, so their rects
558
- // were taken while it was hidden: re-measure once it is open and laid out.
559
- useEffect(() => {
560
- if (!open) return
561
- remeasure()
562
- }, [open, remeasure])
563
-
564
- const checkedIndex = FORMATS.indexOf(value)
565
- const checkedRect = checkedIndex !== -1 ? (itemRects[checkedIndex] ?? null) : null
566
- const focusRect = focusedIndex !== null ? (itemRects[focusedIndex] ?? null) : null
567
- const menuCtx = useMemo(
568
- () => ({ registerItem, activeIndex, checkedIndex }),
569
- [registerItem, activeIndex, checkedIndex],
570
- )
571
-
572
- return (
573
- <Menu.Root
574
- open={open}
575
- onOpenChange={(next, details) => {
576
- if (next) capture(details)
577
- if (!isControlled) setInternalOpen(next)
578
- }}
579
- // Non-modal: the page keeps scrolling and the Positioner tracks the
580
- // anchor, so the popup follows its trigger instead of detaching.
581
- modal={false}
582
- >
583
- <Menu.Trigger
584
- className={cn(
585
- 'flex items-center justify-between bg-transparent hover:bg-hover hover:text-foreground transition-colors duration-fast outline-none focus-visible:ring-1 focus-visible:ring-focus-ring cursor-pointer',
586
- sizeClasses.gap,
587
- sizeClasses.control,
588
- sizeClasses.px,
589
- sizeClasses.text,
590
- open ? 'bg-active text-foreground' : 'text-muted-foreground active:bg-active',
591
- shape.input,
592
- 'weight-medium',
593
- )}
594
- >
595
- <span>{FORMAT_LABELS[value]}</span>
596
- <ChevronDownIcon
597
- size={14}
598
- strokeWidth={1.5}
599
- className={cn('text-muted-foreground transition-transform duration-150', open && 'rotate-180')}
600
- />
601
- </Menu.Trigger>
602
- <Menu.Portal container={portalContainer ?? undefined}>
603
- <Menu.Positioner side="bottom" align="start" sideOffset={6} className="z-[60] outline-none">
604
- <FormatMenuContext.Provider value={menuCtx}>
605
- <Menu.Popup
606
- ref={morph.popupRef}
607
- onMouseEnter={() => {
608
- handlers.onMouseEnter()
609
- setFocusedIndex(null)
610
- }}
611
- onMouseMove={handlers.onMouseMove}
612
- onMouseLeave={handlers.onMouseLeave}
613
- onClick={handlers.onClick}
614
- onFocus={e => {
615
- const indexAttr = (e.target as HTMLElement)
616
- .closest('[data-fluid-hover-index]')
617
- ?.getAttribute('data-fluid-hover-index')
618
- if (indexAttr != null) {
619
- const idx = Number(indexAttr)
620
- setActiveIndex(idx)
621
- setFocusedIndex((e.target as HTMLElement).matches(':focus-visible') ? idx : null)
622
- }
623
- }}
624
- onBlur={e => {
625
- // The popup itself takes focus when the pointer leaves a row.
626
- if (e.currentTarget.contains(e.relatedTarget as Node)) return
627
- setFocusedIndex(null)
628
- setActiveIndex(null)
629
- }}
630
- className="relative select-none outline-none"
631
- >
632
- <SurfaceProvider value={level}>
633
- <MorphSurface
634
- morph={morph}
635
- bg={SURFACE_BG[level]}
636
- shadow={SURFACE_SHADOW[3]}
637
- radius={menuShape.container}
638
- className="min-w-[var(--anchor-width)]"
639
- >
640
- <div ref={containerRef} className="relative flex flex-col p-1">
641
- {/* Selected background */}
642
- <AnimatePresence>
643
- {checkedRect && (
644
- <motion.div
645
- className={`absolute ${menuShape.bg} bg-active pointer-events-none`}
646
- initial={false}
647
- animate={{
648
- top: checkedRect.top,
649
- left: checkedRect.left,
650
- width: checkedRect.width,
651
- height: checkedRect.height,
652
- opacity: 1,
653
- }}
654
- exit={{ opacity: 0, transition: spring.moderate.exit }}
655
- transition={{
656
- ...spring.moderate,
657
- opacity: { duration: spring.fast.duration },
658
- }}
659
- />
660
- )}
661
- </AnimatePresence>
662
-
663
- {/* Hover background */}
664
- <FluidHoverHighlight hover={hover} from={checkedRect} className={menuShape.bg} />
665
-
666
- {/* Focus ring */}
667
- <AnimatePresence>
668
- {focusRect && (
669
- <motion.div
670
- className={`absolute ${menuShape.focusRing} pointer-events-none z-20 border border-focus-ring`}
671
- initial={false}
672
- animate={{
673
- left: focusRect.left - 2,
674
- top: focusRect.top - 2,
675
- width: focusRect.width + 4,
676
- height: focusRect.height + 4,
677
- }}
678
- exit={{ opacity: 0, transition: spring.fast.exit }}
679
- transition={{
680
- ...spring.fast,
681
- opacity: { duration: spring.fast.duration },
682
- }}
683
- />
684
- )}
685
- </AnimatePresence>
686
-
687
- {/* display: contents keeps items direct flex children of the
688
- wrapper so fluid hover measurement and gap layout still work,
689
- while the group provides the radio value context. */}
690
- <Menu.RadioGroup
691
- value={value}
692
- onValueChange={next => onChange(next as ColorFormat)}
693
- className="contents"
694
- >
695
- {FORMATS.map((fmt, i) => (
696
- <FormatItem
697
- key={fmt}
698
- index={i}
699
- value={fmt}
700
- label={FORMAT_LABELS[fmt]}
701
- checked={value === fmt}
702
- />
703
- ))}
704
- </Menu.RadioGroup>
705
- </div>
706
- </MorphSurface>
707
- </SurfaceProvider>
708
- </Menu.Popup>
709
- </FormatMenuContext.Provider>
710
- </Menu.Positioner>
711
- </Menu.Portal>
712
- </Menu.Root>
713
- )
714
- }
715
-
716
- // ---------------------------------------------------------------------------
717
- // ColorInput
718
- //
719
- // Two internal variants behind one API:
720
- // - TextColorInput: draft-based text input (hex).
721
- // - ScrubColorInput: numeric channels, built on Base UI's NumberField whose
722
- // ScrubArea provides pointer-lock scrubbing with a virtual cursor,
723
- // replacing the old hand-rolled pointer-capture logic.
724
- // ---------------------------------------------------------------------------
725
-
726
- interface ColorInputProps {
727
- value: string
728
- onCommit: (next: string) => void
729
- ariaLabel: string
730
- width?: string
731
- className?: string
732
- inputClassName?: string
733
- align?: 'left' | 'center' | 'right'
734
- prefix?: ReactNode
735
- inputMode?: 'numeric' | 'decimal' | 'text'
736
- nudgeStep?: number
737
- nudgeShiftStep?: number
738
- hasPercent?: boolean
739
- decimals?: number
740
- scrubbable?: boolean
741
- min?: number
742
- max?: number
743
- /** When true with min and max, wrap (modulo) instead of clamping. Used for angular values like hue. */
744
- wrap?: boolean
745
- }
746
-
747
- const TextColorInput = forwardRef<HTMLInputElement, ColorInputProps>(
748
- (
749
- {
750
- value,
751
- onCommit,
752
- ariaLabel,
753
- width,
754
- className,
755
- inputClassName,
756
- align = 'left',
757
- prefix,
758
- inputMode = 'text',
759
- nudgeStep,
760
- nudgeShiftStep,
761
- hasPercent = false,
762
- decimals,
763
- min,
764
- max,
765
- wrap = false,
766
- },
767
- ref,
768
- ) => {
769
- const [draft, setDraft] = useState(value)
770
- const interactingRef = useRef(false)
771
- const shape = useShape()
772
- const sizeClasses = useSize()
773
- const compact = sizeClasses.variant === 'compact'
774
-
775
- useEffect(() => {
776
- if (!interactingRef.current) setDraft(value)
777
- }, [value])
778
-
779
- const formatNumber = (n: number) => (decimals != null ? n.toFixed(decimals) : String(Math.round(n)))
780
-
781
- const commitNumber = (n: number) => {
782
- let bounded = n
783
- if (wrap && min != null && max != null) {
784
- const range = max - min
785
- bounded = ((((bounded - min) % range) + range) % range) + min
786
- } else {
787
- if (min != null) bounded = Math.max(min, bounded)
788
- if (max != null) bounded = Math.min(max, bounded)
789
- }
790
- const formatted = formatNumber(bounded)
791
- const withSuffix = hasPercent ? `${formatted}%` : formatted
792
- setDraft(withSuffix)
793
- onCommit(withSuffix)
794
- }
795
-
796
- const nudge = (direction: 1 | -1, shift: boolean) => {
797
- const baseStep = shift ? (nudgeShiftStep ?? 10) : (nudgeStep ?? 1)
798
- const cur = parseFloat(draft.replace('%', ''))
799
- if (Number.isNaN(cur)) return
800
- commitNumber(cur + direction * baseStep)
801
- }
802
-
803
- return (
804
- <div
805
- className={cn(
806
- 'flex items-center px-2 bg-transparent hover:bg-hover active:bg-active transition-colors duration-fast focus-within:ring-1 focus-within:ring-focus-ring select-none',
807
- sizeClasses.control,
808
- shape.input,
809
- className,
810
- )}
811
- style={{ width }}
812
- >
813
- {prefix && (
814
- <span
815
- className={cn('text-muted-foreground mr-1 select-none', compact ? 'text-caption-compact' : 'text-caption')}
816
- >
817
- {prefix}
818
- </span>
819
- )}
820
- <input
821
- ref={ref}
822
- value={draft}
823
- onChange={e => setDraft(e.target.value)}
824
- onFocus={e => {
825
- interactingRef.current = true
826
- e.currentTarget.select()
827
- }}
828
- onBlur={() => {
829
- interactingRef.current = false
830
- if (draft !== value) {
831
- const numeric = parseFloat(draft.replace('%', ''))
832
- if (!Number.isNaN(numeric) && (min != null || max != null)) {
833
- commitNumber(numeric)
834
- } else {
835
- onCommit(draft)
836
- }
837
- } else setDraft(value)
838
- }}
839
- onKeyDown={e => {
840
- if (e.key === 'Enter') {
841
- ;(e.currentTarget as HTMLInputElement).blur()
842
- } else if (e.key === 'Escape') {
843
- setDraft(value)
844
- ;(e.currentTarget as HTMLInputElement).blur()
845
- } else if (
846
- (nudgeStep != null || nudgeShiftStep != null) &&
847
- (e.key === 'ArrowUp' || e.key === 'ArrowDown')
848
- ) {
849
- e.preventDefault()
850
- nudge(e.key === 'ArrowUp' ? 1 : -1, e.shiftKey)
851
- }
852
- }}
853
- inputMode={inputMode}
854
- aria-label={ariaLabel}
855
- className={cn(
856
- 'flex-1 min-w-0 bg-transparent text-foreground outline-none tabular-nums',
857
- sizeClasses.text,
858
- align === 'center' && 'text-center',
859
- align === 'right' && 'text-right',
860
- 'weight-medium',
861
- inputClassName,
862
- )}
863
- />
864
- </div>
865
- )
866
- },
867
- )
868
-
869
- TextColorInput.displayName = 'TextColorInput'
870
-
871
- const ScrubColorInput = forwardRef<HTMLInputElement, ColorInputProps>(
872
- (
873
- {
874
- value,
875
- onCommit,
876
- ariaLabel,
877
- width,
878
- className,
879
- inputClassName,
880
- align = 'left',
881
- prefix,
882
- inputMode = 'numeric',
883
- nudgeStep,
884
- nudgeShiftStep,
885
- hasPercent = false,
886
- decimals,
887
- min,
888
- max,
889
- wrap = false,
890
- },
891
- ref,
892
- ) => {
893
- const shape = useShape()
894
- const sizeClasses = useSize()
895
- const compact = sizeClasses.variant === 'compact'
896
- const inputRef = useRef<HTMLInputElement | null>(null)
897
- const [editing, setEditing] = useState(false)
898
- // Set on pointerdown inside the scrub area (capture phase, before Base UI
899
- // focuses the input for scrubbing) so onFocus can tell scrub-focus apart
900
- // from keyboard/programmatic focus.
901
- const pointerDownRef = useRef(false)
902
-
903
- const numeric = parseFloat(String(value).replace('%', ''))
904
- const fieldValue = Number.isNaN(numeric) ? null : numeric
905
-
906
- const format = useMemo(() => {
907
- const f: Intl.NumberFormatOptions = { useGrouping: false }
908
- if (decimals != null) {
909
- f.minimumFractionDigits = decimals
910
- f.maximumFractionDigits = decimals
911
- } else {
912
- f.maximumFractionDigits = 0
913
- }
914
- if (hasPercent) {
915
- // style "unit" + unit "percent" renders "50%" while keeping the
916
- // numeric value on the 0..100 scale (unlike style "percent").
917
- f.style = 'unit'
918
- f.unit = 'percent'
919
- }
920
- return f
921
- }, [decimals, hasPercent])
922
-
923
- const setInputRef = useCallback(
924
- (node: HTMLInputElement | null) => {
925
- inputRef.current = node
926
- if (typeof ref === 'function') ref(node)
927
- else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = node
928
- },
929
- [ref],
930
- )
931
-
932
- const commit = useCallback(
933
- (n: number) => {
934
- let bounded = n
935
- if (wrap && min != null && max != null) {
936
- // Hue-style wrap: NumberField won't wrap natively, so shim it here
937
- // (361 → 1, -1 → 359; exactly `max` stays put).
938
- if (bounded < min || bounded > max) {
939
- const range = max - min
940
- bounded = ((((bounded - min) % range) + range) % range) + min
941
- }
942
- } else {
943
- if (min != null) bounded = Math.max(min, bounded)
944
- if (max != null) bounded = Math.min(max, bounded)
945
- }
946
- const formatted = decimals != null ? bounded.toFixed(decimals) : String(Math.round(bounded))
947
- onCommit(hasPercent ? `${formatted}%` : formatted)
948
- },
949
- [wrap, min, max, decimals, hasPercent, onCommit],
950
- )
951
-
952
- return (
953
- <NumberField.Root
954
- value={fieldValue}
955
- onValueChange={(next, eventDetails) => {
956
- if (next == null) return
957
- const reason = eventDetails.reason
958
- // Preserve the old commit-on-blur typing semantics: ignore the
959
- // per-keystroke parses and let the input-blur change land the final
960
- // value. Keyboard nudges, scrubbing, and wheel commit immediately.
961
- if (reason === 'input-change' || reason === 'input-paste' || reason === 'input-clear') {
962
- return
963
- }
964
- commit(next)
965
- }}
966
- onValueCommitted={(_, eventDetails) => {
967
- // After a scrub gesture ends, drop the focus Base UI placed on the
968
- // input so the field returns to its rest state (matching the old
969
- // behavior). For a no-drag press, ScrubArea dispatches a synthetic
970
- // click right after this, which re-enters edit mode below.
971
- if (eventDetails.reason === 'scrub') {
972
- pointerDownRef.current = false
973
- inputRef.current?.blur()
974
- }
975
- }}
976
- min={wrap ? undefined : min}
977
- max={wrap ? undefined : max}
978
- step={nudgeStep ?? 1}
979
- largeStep={nudgeShiftStep ?? 10}
980
- format={format}
981
- className={cn(
982
- 'flex items-center bg-transparent hover:bg-hover active:bg-active transition-colors duration-fast focus-within:ring-1 focus-within:ring-focus-ring select-none',
983
- sizeClasses.control,
984
- shape.input,
985
- className,
986
- )}
987
- style={{ width }}
988
- >
989
- <NumberField.ScrubArea
990
- direction="horizontal"
991
- pixelSensitivity={1}
992
- onPointerDownCapture={() => {
993
- pointerDownRef.current = true
994
- }}
995
- onClick={() => {
996
- // Real clicks and the synthetic click ScrubArea dispatches after a
997
- // no-drag press both land here → enter edit mode (focus + select),
998
- // like the old click-to-edit behavior.
999
- pointerDownRef.current = false
1000
- setEditing(true)
1001
- inputRef.current?.focus()
1002
- inputRef.current?.select()
1003
- }}
1004
- className={cn('flex flex-1 min-w-0 items-center self-stretch px-2', !editing && 'cursor-ew-resize')}
1005
- >
1006
- <NumberField.ScrubAreaCursor className="drop-shadow-picker-cursor">
1007
- <svg
1008
- width={24}
1009
- height={14}
1010
- viewBox="0 0 24 14"
1011
- className="fill-picker-edge stroke-thumb"
1012
- strokeWidth={1}
1013
- aria-hidden="true"
1014
- >
1015
- <path d="M0.5 7l5-5v3.5h13V2l5 5-5 5V8.5h-13V12l-5-5z" />
1016
- </svg>
1017
- </NumberField.ScrubAreaCursor>
1018
- {prefix && (
1019
- <span
1020
- className={cn(
1021
- 'text-muted-foreground mr-1 select-none',
1022
- compact ? 'text-caption-compact' : 'text-caption',
1023
- )}
1024
- >
1025
- {prefix}
1026
- </span>
1027
- )}
1028
- <NumberField.Input
1029
- ref={setInputRef}
1030
- aria-label={ariaLabel}
1031
- inputMode={inputMode}
1032
- onPointerDown={e => {
1033
- // While editing, let the input handle caret placement and text
1034
- // selection itself instead of starting a scrub gesture.
1035
- if (editing) e.stopPropagation()
1036
- }}
1037
- onFocus={e => {
1038
- if (pointerDownRef.current) return // scrub-initiated focus
1039
- setEditing(true)
1040
- e.currentTarget.select()
1041
- }}
1042
- onBlur={() => {
1043
- setEditing(false)
1044
- }}
1045
- onKeyDown={e => {
1046
- if (e.key === 'Enter') {
1047
- e.currentTarget.blur()
1048
- } else if (e.key === 'Escape') {
1049
- // Revert the draft like the old input: restore the committed
1050
- // value's text before blurring so the input-blur commit is a
1051
- // no-op.
1052
- const input = e.currentTarget
1053
- const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
1054
- if (setter && fieldValue != null) {
1055
- const restored = decimals != null ? fieldValue.toFixed(decimals) : String(Math.round(fieldValue))
1056
- setter.call(input, hasPercent ? `${restored}%` : restored)
1057
- input.dispatchEvent(new Event('input', { bubbles: true }))
1058
- }
1059
- input.blur()
1060
- }
1061
- }}
1062
- className={cn(
1063
- 'flex-1 min-w-0 bg-transparent text-foreground outline-none tabular-nums',
1064
- sizeClasses.text,
1065
- align === 'center' && 'text-center',
1066
- align === 'right' && 'text-right',
1067
- !editing && 'pointer-events-none',
1068
- 'weight-medium',
1069
- inputClassName,
1070
- )}
1071
- />
1072
- </NumberField.ScrubArea>
1073
- </NumberField.Root>
1074
- )
1075
- },
1076
- )
1077
-
1078
- ScrubColorInput.displayName = 'ScrubColorInput'
1079
-
1080
- const ColorInput = forwardRef<HTMLInputElement, ColorInputProps>(({ scrubbable = false, ...props }, ref) =>
1081
- scrubbable ? <ScrubColorInput ref={ref} {...props} /> : <TextColorInput ref={ref} {...props} />,
1082
- )
1083
-
1084
- ColorInput.displayName = 'ColorInput'
1085
-
1086
- // ---------------------------------------------------------------------------
1087
- // EyeDropperButton
1088
- //
1089
- // `window.EyeDropper` is not in TypeScript's DOM lib, so the constructor is
1090
- // read through a cast (upstream does the same) rather than a global
1091
- // declaration — feature detection stays a runtime `in` check.
1092
- // ---------------------------------------------------------------------------
1093
-
1094
- interface EyeDropperGlobal {
1095
- open(): Promise<{ sRGBHex: string }>
1096
- }
1097
-
1098
- function EyeDropperButton({ onPick }: { onPick: (hex: string) => void }) {
1099
- const [supported, setSupported] = useState(false)
1100
- const shape = useShape()
1101
- const sizeClasses = useSize()
1102
- const PipetteIcon = useIcon('pipette')
1103
-
1104
- useEffect(() => {
1105
- setSupported(typeof window !== 'undefined' && 'EyeDropper' in window)
1106
- }, [])
1107
-
1108
- if (!supported) return null
1109
-
1110
- const handleClick = async () => {
1111
- try {
1112
- const Ctor = (window as unknown as { EyeDropper: new () => EyeDropperGlobal }).EyeDropper
1113
- const eye = new Ctor()
1114
- const result = await eye.open()
1115
- onPick(result.sRGBHex)
1116
- } catch {
1117
- // user cancelled
1118
- }
1119
- }
1120
-
1121
- return (
1122
- <button
1123
- type="button"
1124
- onClick={handleClick}
1125
- aria-label="Pick color from screen"
1126
- className={cn(
1127
- 'flex items-center justify-center text-muted-foreground bg-transparent hover:bg-hover hover:text-foreground active:bg-active transition-colors duration-fast outline-none focus-visible:ring-1 focus-visible:ring-focus-ring cursor-pointer',
1128
- sizeClasses.control,
1129
- sizeClasses.px,
1130
- shape.input,
1131
- )}
1132
- >
1133
- <PipetteIcon size={sizeClasses.icon} strokeWidth={1.5} />
1134
- </button>
1135
- )
1136
- }
1137
-
1138
- // ---------------------------------------------------------------------------
1139
- // ColorTile (small colored square — checker behind alpha)
1140
- // ---------------------------------------------------------------------------
1141
-
1142
- interface ColorTileProps {
1143
- color: string
1144
- size?: number
1145
- className?: string
1146
- style?: CSSProperties
1147
- }
1148
-
1149
- function ColorTile({ color, size = 24, className, style }: ColorTileProps) {
1150
- const shape = useShape()
1151
- return (
1152
- <span
1153
- className={cn('inline-block relative shrink-0 overflow-hidden shadow-swatch', shape.bg, className)}
1154
- style={{
1155
- width: size,
1156
- height: size,
1157
- ...CHECKER_BG,
1158
- ...style,
1159
- }}
1160
- >
1161
- <span className="absolute inset-0" style={{ backgroundColor: color }} />
1162
- <span aria-hidden className={cn('absolute inset-0 pointer-events-none shadow-swatch', shape.bg)} />
1163
- </span>
1164
- )
1165
- }
1166
-
1167
- // ---------------------------------------------------------------------------
1168
- // ColorSwatch (clickable strip swatch)
1169
- // ---------------------------------------------------------------------------
1170
-
1171
- const ColorSwatch = forwardRef<HTMLButtonElement, ColorSwatchProps>(
1172
- ({ color, size = 28, selected, className, onMouseEnter, onMouseLeave, ...props }, ref) => {
1173
- const shape = useShape()
1174
- const [hovered, setHovered] = useState(false)
1175
- const ring = selected ? 'shadow-swatch-selected' : hovered ? 'shadow-swatch-hover' : 'shadow-swatch'
1176
- return (
1177
- <button
1178
- ref={ref}
1179
- type="button"
1180
- aria-label={`Select color ${color}`}
1181
- className={cn(
1182
- 'relative shrink-0 overflow-hidden cursor-pointer outline-none transition-shadow duration-100',
1183
- shape.bg,
1184
- ring,
1185
- className,
1186
- )}
1187
- style={{
1188
- width: size,
1189
- height: size,
1190
- ...CHECKER_BG,
1191
- }}
1192
- onMouseEnter={e => {
1193
- setHovered(true)
1194
- onMouseEnter?.(e)
1195
- }}
1196
- onMouseLeave={e => {
1197
- setHovered(false)
1198
- onMouseLeave?.(e)
1199
- }}
1200
- {...props}
1201
- >
1202
- <span className="absolute inset-0" style={{ backgroundColor: color }} />
1203
- <span aria-hidden className={cn('absolute inset-0 pointer-events-none shadow-swatch', shape.bg)} />
1204
- </button>
1205
- )
1206
- },
1207
- )
1208
-
1209
- ColorSwatch.displayName = 'ColorSwatch'
1210
-
1211
- // ---------------------------------------------------------------------------
1212
- // SwatchStrip
1213
- // ---------------------------------------------------------------------------
1214
-
1215
- function SwatchStrip({
1216
- swatches,
1217
- current,
1218
- onPick,
1219
- }: {
1220
- swatches: string[]
1221
- current: string
1222
- onPick: (color: string) => void
1223
- }) {
1224
- const normalizedCurrent = useMemo(() => {
1225
- const p = parseColor(current)
1226
- return p ? rgbToHexStr(p.r, p.g, p.b, p.a).toLowerCase() : ''
1227
- }, [current])
1228
-
1229
- // Named CSS colors ("red", "tomato") need the browser to normalize before
1230
- // the selected-state comparison can match. Resolve them in an effect so
1231
- // render (and SSR) never touch the DOM.
1232
- const [resolvedSwatches, setResolvedSwatches] = useState<Record<string, string>>({})
1233
- useEffect(() => {
1234
- const next: Record<string, string> = {}
1235
- for (const sw of swatches) {
1236
- if (!parseColor(sw)) {
1237
- const p = resolveCssColor(sw)
1238
- if (p) next[sw] = rgbToHexStr(p.r, p.g, p.b, p.a).toLowerCase()
1239
- }
1240
- }
1241
- setResolvedSwatches(next)
1242
- }, [swatches])
1243
-
1244
- return (
1245
- <div className="flex flex-wrap gap-2">
1246
- {swatches.map((sw, i) => {
1247
- const parsed = parseColor(sw)
1248
- const normalized = parsed
1249
- ? rgbToHexStr(parsed.r, parsed.g, parsed.b, parsed.a).toLowerCase()
1250
- : (resolvedSwatches[sw] ?? sw.toLowerCase())
1251
- const isSelected = normalized === normalizedCurrent
1252
- return <ColorSwatch key={`${sw}-${i}`} color={sw} size={28} selected={isSelected} onClick={() => onPick(sw)} />
1253
- })}
1254
- </div>
1255
- )
1256
- }
1257
-
1258
- // ---------------------------------------------------------------------------
1259
- // ColorPicker (panel)
1260
- // ---------------------------------------------------------------------------
1261
-
1262
- type ColorPickerComponent = ForwardRefExoticComponent<ColorPickerProps & RefAttributes<HTMLDivElement>> & {
1263
- /** `ColorPickerPopover` — the same panel behind a trigger button. */
1264
- Popover: typeof ColorPickerPopover
1265
- }
1266
-
1267
- /**
1268
- * Color picker with HEX, RGB, HSL, and OKLCH formats, an alpha channel,
1269
- * optional swatches, and an eyedropper. Available as an inline panel or a
1270
- * popover.
1271
- *
1272
- * The value is kept internally as HSV, so hue survives a pass through pure
1273
- * black, pure white or full grey instead of collapsing to red. Every edit —
1274
- * the saturation square, the hue and alpha sliders, the per-channel inputs,
1275
- * a swatch, the eyedropper — writes to that one canonical color, which is
1276
- * re-emitted in the active format together with a parsed object carrying all
1277
- * four. Numeric channels scrub: drag sideways to sweep a value, click to type
1278
- * it. Alpha reads against a checkerboard, swatches accept any CSS color string
1279
- * (named colors included), and OKLCH edits keep the hue you typed across the
1280
- * lossy trip back through sRGB. The panel paints itself on the surface ladder
1281
- * and re-provides its level, so the format menu elevates above it however
1282
- * deeply the picker is nested.
1283
- *
1284
- * Statics:
1285
- * - `ColorPicker.Popover` — `ColorPickerPopover`: the same panel behind a
1286
- * trigger button showing a color tile, an optional label and the hex value.
1287
- * The panel oozes out of the trigger and back through the shared morph (see
1288
- * Morph), with the morph options `from`, `effect`, `hideSource`, `tier`.
1289
- *
1290
- * Also exported: `ColorSwatch` and `ColorTile` (the swatch button and the
1291
- * checkerboard tile), `ColorPickerPortalContainer` (portal the format menu
1292
- * into a given element — e.g. inside a CSS-scaled ancestor), and the
1293
- * `parseColor` / `buildParsed` color helpers.
1294
- *
1295
- * @example {@include ./examples.mdx}
1296
- */
1297
- const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
1298
- (
1299
- {
1300
- value,
1301
- defaultValue = '#6B97FF',
1302
- onValueChange,
1303
- format,
1304
- defaultFormat = 'hex',
1305
- onFormatChange,
1306
- swatches,
1307
- hideEyedropper,
1308
- formatOpen,
1309
- defaultFormatOpen,
1310
- size,
1311
- className,
1312
- ...props
1313
- },
1314
- ref,
1315
- ) => {
1316
- const isControlled = value !== undefined
1317
- const [internalValue, setInternalValue] = useState(value ?? defaultValue)
1318
- const currentRawValue = isControlled ? (value as string) : internalValue
1319
-
1320
- const isFormatControlled = format !== undefined
1321
- const [internalFormat, setInternalFormat] = useState<ColorFormat>(defaultFormat)
1322
- const currentFormat = isFormatControlled ? (format as ColorFormat) : internalFormat
1323
-
1324
- // Internal HSV state (canonical). H is preserved across S=0 / V=0
1325
- // transitions. Deliberately computed once from the initial value only.
1326
- const initialParsed = useMemo(() => {
1327
- const p = parseColor(currentRawValue)
1328
- if (!p) return { h: 0, s: 1, v: 1, a: 1 }
1329
- const hsv = rgbToHsv(p.r, p.g, p.b)
1330
- return { h: hsv.s === 0 ? 0 : hsv.h, s: hsv.s, v: hsv.v, a: p.a }
1331
- }, [])
1332
-
1333
- const [hsv, setHsv] = useState(initialParsed)
1334
-
1335
- // Sticky OKLCH hue: preserves the user's stated OKLCH H across the lossy
1336
- // RGB round-trip (so the displayed H doesn't drift after release) and
1337
- // across achromatic colors (where RGB-derived H would collapse to 0).
1338
- // Cleared whenever the color changes through a non-OKLCH-internal channel.
1339
- const oklchHueRef = useRef<number | null>(null)
1340
-
1341
- // External value sync — when controlled value changes from outside, sync HSV
1342
- const lastEmittedRef = useRef<string>('')
1343
- useEffect(() => {
1344
- if (!isControlled) return
1345
- const emitted = lastEmittedRef.current
1346
- const cur = value as string
1347
- if (cur === emitted) return
1348
- const p = parseColor(cur)
1349
- if (!p) return
1350
- oklchHueRef.current = null
1351
- const newHsv = rgbToHsv(p.r, p.g, p.b)
1352
- setHsv(prev => ({
1353
- h: newHsv.s === 0 ? prev.h : newHsv.h,
1354
- s: newHsv.s,
1355
- v: newHsv.v,
1356
- a: p.a,
1357
- }))
1358
- }, [value, isControlled])
1359
-
1360
- const parsed = useMemo(() => buildParsed(hsv.h, hsv.s, hsv.v, hsv.a), [hsv])
1361
-
1362
- const updateHsv = useCallback(
1363
- (next: { h?: number; s?: number; v?: number; a?: number }) => {
1364
- const merged = { ...hsv, ...next }
1365
- setHsv(merged)
1366
- const p = buildParsed(merged.h, merged.s, merged.v, merged.a)
1367
- const formatted = formatValueByFormat(p, currentFormat)
1368
- lastEmittedRef.current = formatted
1369
- if (!isControlled) setInternalValue(formatted)
1370
- onValueChange?.(formatted, p)
1371
- },
1372
- [hsv, currentFormat, isControlled, onValueChange],
1373
- )
1374
-
1375
- const handleFormatChange = useCallback(
1376
- (f: ColorFormat) => {
1377
- if (!isFormatControlled) setInternalFormat(f)
1378
- onFormatChange?.(f)
1379
- // Re-emit value in new format
1380
- const formatted = formatValueByFormat(parsed, f)
1381
- lastEmittedRef.current = formatted
1382
- if (!isControlled) setInternalValue(formatted)
1383
- onValueChange?.(formatted, parsed)
1384
- },
1385
- [isFormatControlled, isControlled, onFormatChange, onValueChange, parsed],
1386
- )
1387
-
1388
- const handleHexCommit = useCallback(
1389
- (input: string) => {
1390
- // resolveCssColor falls back to browser normalization so named CSS
1391
- // colors ("red", "tomato") from swatches or the hex field work too.
1392
- // Safe here: this only ever runs inside event handlers.
1393
- const p = resolveCssColor(input)
1394
- if (!p) return
1395
- oklchHueRef.current = null
1396
- const newHsv = rgbToHsv(p.r, p.g, p.b)
1397
- const merged = {
1398
- h: newHsv.s === 0 ? hsv.h : newHsv.h,
1399
- s: newHsv.s,
1400
- v: newHsv.v,
1401
- a: p.a,
1402
- }
1403
- setHsv(merged)
1404
- const next = buildParsed(merged.h, merged.s, merged.v, merged.a)
1405
- const formatted = formatValueByFormat(next, currentFormat)
1406
- lastEmittedRef.current = formatted
1407
- if (!isControlled) setInternalValue(formatted)
1408
- onValueChange?.(formatted, next)
1409
- },
1410
- [hsv.h, currentFormat, isControlled, onValueChange],
1411
- )
1412
-
1413
- const handleSwatchPick = useCallback(
1414
- (sw: string) => {
1415
- handleHexCommit(sw)
1416
- },
1417
- [handleHexCommit],
1418
- )
1419
-
1420
- const handleEyedrop = useCallback(
1421
- (hex: string) => {
1422
- handleHexCommit(hex)
1423
- },
1424
- [handleHexCommit],
1425
- )
1426
-
1427
- const solidHueRgb = useMemo(() => hsvToRgb(hsv.h, hsv.s, hsv.v), [hsv.h, hsv.s, hsv.v])
1428
- const solidR = Math.round(solidHueRgb.r)
1429
- const solidG = Math.round(solidHueRgb.g)
1430
- const solidB = Math.round(solidHueRgb.b)
1431
- const solidColorString = `rgb(${solidR}, ${solidG}, ${solidB})`
1432
- const shape = useShape()
1433
- const substrate = useSurface()
1434
- // The picker panel uses bg-card (surface-3) by default; when wrapped in
1435
- // ColorPickerPopover the className override pushes it higher. Either way,
1436
- // announce the panel's effective level so descendants (FormatDropdown,
1437
- // etc.) elevate above it instead of colliding at the same surface.
1438
- const pickerLevel = Math.max(substrate, 3)
1439
- const paintsSurface = useContext(PanelSurfaceContext)
1440
-
1441
- // A size prop pins the whole panel — format dropdown, inputs, eyedropper
1442
- // (React context crosses portals) — to one step of the ladder.
1443
- const root = (
1444
- <SurfaceProvider value={pickerLevel}>
1445
- <div
1446
- ref={ref}
1447
- className={cn(
1448
- 'flex flex-col gap-2 p-3',
1449
- paintsSurface && surfaceClasses(pickerLevel, 1),
1450
- shape.container,
1451
- className,
1452
- )}
1453
- style={{ width: PANEL_WIDTH }}
1454
- {...props}
1455
- >
1456
- <SaturationSquare h={hsv.h} s={hsv.s} v={hsv.v} onChange={(s, v) => updateHsv({ s, v })} />
1457
-
1458
- <div className="flex flex-col [&>*]:mb-0 [&>*+*]:-mt-px">
1459
- <HueSlider
1460
- h={hsv.h}
1461
- onChange={h => {
1462
- oklchHueRef.current = null
1463
- updateHsv({ h })
1464
- }}
1465
- />
1466
- <AlphaSlider
1467
- a={hsv.a}
1468
- solidColor={solidColorString}
1469
- solidR={solidR}
1470
- solidG={solidG}
1471
- solidB={solidB}
1472
- onChange={a => updateHsv({ a })}
1473
- />
1474
- </div>
1475
-
1476
- <div className="grid grid-cols-2 gap-2">
1477
- <FormatDropdown
1478
- value={currentFormat}
1479
- onChange={handleFormatChange}
1480
- open={formatOpen}
1481
- defaultOpen={defaultFormatOpen}
1482
- />
1483
- {!hideEyedropper && <EyeDropperButton onPick={handleEyedrop} />}
1484
- </div>
1485
-
1486
- <ColorInputsRow
1487
- parsed={parsed}
1488
- format={currentFormat}
1489
- oklchHue={oklchHueRef.current}
1490
- onChannelChange={(channel, value) => {
1491
- const p = { ...parsed }
1492
- switch (channel) {
1493
- case 'hex':
1494
- handleHexCommit(value as string)
1495
- return
1496
- case 'r':
1497
- case 'g':
1498
- case 'b': {
1499
- oklchHueRef.current = null
1500
- const r = channel === 'r' ? Number(value) : p.r
1501
- const g = channel === 'g' ? Number(value) : p.g
1502
- const b = channel === 'b' ? Number(value) : p.b
1503
- const hsvVal = rgbToHsv(r, g, b)
1504
- updateHsv({
1505
- h: hsvVal.s === 0 ? hsv.h : hsvVal.h,
1506
- s: hsvVal.s,
1507
- v: hsvVal.v,
1508
- })
1509
- return
1510
- }
1511
- case 'hSL':
1512
- case 'sSL':
1513
- case 'lSL': {
1514
- if (channel === 'hSL') oklchHueRef.current = null
1515
- const hsl = rgbToHsl(p.r, p.g, p.b)
1516
- const h2 = channel === 'hSL' ? Number(value) : hsl.h
1517
- const s2 = channel === 'sSL' ? Number(value) / 100 : hsl.s
1518
- const l2 = channel === 'lSL' ? Number(value) / 100 : hsl.l
1519
- const rgb = hslToRgb(h2, clamp01(s2), clamp01(l2))
1520
- const hsvVal = rgbToHsv(rgb.r, rgb.g, rgb.b)
1521
- updateHsv({
1522
- h: hsvVal.s === 0 ? h2 : hsvVal.h,
1523
- s: hsvVal.s,
1524
- v: hsvVal.v,
1525
- })
1526
- return
1527
- }
1528
- case 'L':
1529
- case 'C':
1530
- case 'H': {
1531
- const cur = rgbToOklch(p.r, p.g, p.b)
1532
- // For L/C edits, anchor on the user's last stated H so we
1533
- // don't drift along with chroma changes.
1534
- const baseH = oklchHueRef.current ?? cur.H
1535
- const L = channel === 'L' ? Number(value) / 100 : cur.L
1536
- const C = channel === 'C' ? Number(value) : cur.C
1537
- const H = channel === 'H' ? Number(value) : baseH
1538
- oklchHueRef.current = H
1539
- const rgb = oklchToRgb(clamp01(L), Math.max(0, C), H)
1540
- const hsvVal = rgbToHsv(rgb.r, rgb.g, rgb.b)
1541
- updateHsv({
1542
- h: hsvVal.s === 0 ? hsv.h : hsvVal.h,
1543
- s: hsvVal.s,
1544
- v: hsvVal.v,
1545
- })
1546
- return
1547
- }
1548
- case 'alphaPercent': {
1549
- const a = clamp01(Number(value) / 100)
1550
- updateHsv({ a })
1551
- return
1552
- }
1553
- }
1554
- }}
1555
- />
1556
-
1557
- {swatches && swatches.length > 0 && (
1558
- <SwatchStrip swatches={swatches} current={parsed.hex} onPick={handleSwatchPick} />
1559
- )}
1560
- </div>
1561
- </SurfaceProvider>
1562
- )
1563
-
1564
- return size ? <SizeProvider size={size}>{root}</SizeProvider> : root
1565
- },
1566
- ) as ColorPickerComponent
1567
-
1568
- ColorPicker.displayName = 'ColorPicker'
1569
-
1570
- // ---------------------------------------------------------------------------
1571
- // ColorInputsRow — adapts inputs to format
1572
- // ---------------------------------------------------------------------------
1573
-
1574
- type ChannelKey = 'hex' | 'r' | 'g' | 'b' | 'hSL' | 'sSL' | 'lSL' | 'L' | 'C' | 'H' | 'alphaPercent'
1575
-
1576
- function ColorInputsRow({
1577
- parsed,
1578
- format,
1579
- oklchHue,
1580
- onChannelChange,
1581
- }: {
1582
- parsed: ParsedColor
1583
- format: ColorFormat
1584
- /** Sticky OKLCH hue override for display (preserves user's stated H across round-trip drift). */
1585
- oklchHue?: number | null
1586
- onChannelChange: (key: ChannelKey, value: string) => void
1587
- }) {
1588
- const alphaPct = Math.round(parsed.a * 100)
1589
-
1590
- if (format === 'hex') {
1591
- const hexNoHash = parsed.hex.replace(/^#/, '').toUpperCase()
1592
- return (
1593
- <div className="grid grid-cols-2 gap-2">
1594
- <ChannelTooltip label="Hex">
1595
- <ColorInput
1596
- value={hexNoHash}
1597
- onCommit={next => onChannelChange('hex', next.startsWith('#') ? next : `#${next}`)}
1598
- ariaLabel="Hex value"
1599
- prefix="#"
1600
- />
1601
- </ChannelTooltip>
1602
- <AlphaInput value={alphaPct} onCommit={n => onChannelChange('alphaPercent', String(n))} />
1603
- </div>
1604
- )
1605
- }
1606
-
1607
- if (format === 'rgb') {
1608
- return (
1609
- <div className="grid grid-cols-4 gap-1">
1610
- <ChannelTooltip label="Red">
1611
- <ColorInput
1612
- value={String(parsed.r)}
1613
- onCommit={n => onChannelChange('r', n)}
1614
- ariaLabel="Red"
1615
- align="center"
1616
- inputMode="numeric"
1617
- nudgeStep={1}
1618
- nudgeShiftStep={10}
1619
- scrubbable
1620
- min={0}
1621
- max={255}
1622
- />
1623
- </ChannelTooltip>
1624
- <ChannelTooltip label="Green">
1625
- <ColorInput
1626
- value={String(parsed.g)}
1627
- onCommit={n => onChannelChange('g', n)}
1628
- ariaLabel="Green"
1629
- align="center"
1630
- inputMode="numeric"
1631
- nudgeStep={1}
1632
- nudgeShiftStep={10}
1633
- scrubbable
1634
- min={0}
1635
- max={255}
1636
- />
1637
- </ChannelTooltip>
1638
- <ChannelTooltip label="Blue">
1639
- <ColorInput
1640
- value={String(parsed.b)}
1641
- onCommit={n => onChannelChange('b', n)}
1642
- ariaLabel="Blue"
1643
- align="center"
1644
- inputMode="numeric"
1645
- nudgeStep={1}
1646
- nudgeShiftStep={10}
1647
- scrubbable
1648
- min={0}
1649
- max={255}
1650
- />
1651
- </ChannelTooltip>
1652
- <AlphaInput value={alphaPct} onCommit={n => onChannelChange('alphaPercent', String(n))} />
1653
- </div>
1654
- )
1655
- }
1656
-
1657
- if (format === 'hsl') {
1658
- const hsl = rgbToHsl(parsed.r, parsed.g, parsed.b)
1659
- return (
1660
- <div className="grid grid-cols-4 gap-1">
1661
- <ChannelTooltip label="Hue">
1662
- <ColorInput
1663
- value={String(Math.round(hsl.h))}
1664
- onCommit={n => onChannelChange('hSL', n)}
1665
- ariaLabel="Hue"
1666
- align="center"
1667
- inputMode="numeric"
1668
- nudgeStep={1}
1669
- nudgeShiftStep={10}
1670
- scrubbable
1671
- min={0}
1672
- max={360}
1673
- wrap
1674
- />
1675
- </ChannelTooltip>
1676
- <ChannelTooltip label="Saturation">
1677
- <ColorInput
1678
- value={String(Math.round(hsl.s * 100))}
1679
- onCommit={n => onChannelChange('sSL', n)}
1680
- ariaLabel="Saturation"
1681
- align="center"
1682
- inputMode="numeric"
1683
- nudgeStep={1}
1684
- nudgeShiftStep={10}
1685
- scrubbable
1686
- min={0}
1687
- max={100}
1688
- />
1689
- </ChannelTooltip>
1690
- <ChannelTooltip label="Lightness">
1691
- <ColorInput
1692
- value={String(Math.round(hsl.l * 100))}
1693
- onCommit={n => onChannelChange('lSL', n)}
1694
- ariaLabel="Lightness"
1695
- align="center"
1696
- inputMode="numeric"
1697
- nudgeStep={1}
1698
- nudgeShiftStep={10}
1699
- scrubbable
1700
- min={0}
1701
- max={100}
1702
- />
1703
- </ChannelTooltip>
1704
- <AlphaInput value={alphaPct} onCommit={n => onChannelChange('alphaPercent', String(n))} />
1705
- </div>
1706
- )
1707
- }
1708
-
1709
- // oklch
1710
- const oklch = rgbToOklch(parsed.r, parsed.g, parsed.b)
1711
- const displayH = oklchHue ?? oklch.H
1712
- return (
1713
- <div className="grid grid-cols-4 gap-1">
1714
- <ChannelTooltip label="Lightness">
1715
- <ColorInput
1716
- value={(oklch.L * 100).toFixed(0)}
1717
- onCommit={n => onChannelChange('L', n)}
1718
- ariaLabel="Lightness"
1719
- align="center"
1720
- inputMode="decimal"
1721
- nudgeStep={1}
1722
- nudgeShiftStep={10}
1723
- scrubbable
1724
- min={0}
1725
- max={100}
1726
- />
1727
- </ChannelTooltip>
1728
- <ChannelTooltip label="Chroma">
1729
- <ColorInput
1730
- value={oklch.C.toFixed(2)}
1731
- onCommit={n => onChannelChange('C', n)}
1732
- ariaLabel="Chroma"
1733
- align="center"
1734
- inputMode="decimal"
1735
- nudgeStep={0.01}
1736
- nudgeShiftStep={0.1}
1737
- decimals={2}
1738
- scrubbable
1739
- min={0}
1740
- max={0.4}
1741
- />
1742
- </ChannelTooltip>
1743
- <ChannelTooltip label="Hue">
1744
- <ColorInput
1745
- value={displayH.toFixed(0)}
1746
- onCommit={n => onChannelChange('H', n)}
1747
- ariaLabel="Hue"
1748
- align="center"
1749
- inputMode="numeric"
1750
- nudgeStep={1}
1751
- nudgeShiftStep={10}
1752
- scrubbable
1753
- min={0}
1754
- max={360}
1755
- wrap
1756
- />
1757
- </ChannelTooltip>
1758
- <AlphaInput value={alphaPct} onCommit={n => onChannelChange('alphaPercent', String(n))} />
1759
- </div>
1760
- )
1761
- }
1762
-
1763
- function ChannelTooltip({ label, children }: { label: string; children: ReactNode }) {
1764
- return (
1765
- <Tooltip content={label} delayDuration={300}>
1766
- <div>{children}</div>
1767
- </Tooltip>
1768
- )
1769
- }
1770
-
1771
- function AlphaInput({ value, onCommit }: { value: number; onCommit: (n: number) => void }) {
1772
- return (
1773
- <ChannelTooltip label="Alpha">
1774
- <ColorInput
1775
- value={`${value}%`}
1776
- onCommit={input => {
1777
- const n = parseFloat(input.replace('%', ''))
1778
- if (Number.isNaN(n)) return
1779
- onCommit(Math.max(0, Math.min(100, Math.round(n))))
1780
- }}
1781
- ariaLabel="Alpha"
1782
- align="center"
1783
- inputMode="numeric"
1784
- nudgeStep={1}
1785
- nudgeShiftStep={10}
1786
- hasPercent
1787
- scrubbable
1788
- min={0}
1789
- max={100}
1790
- />
1791
- </ChannelTooltip>
1792
- )
1793
- }
1794
-
1795
- // ---------------------------------------------------------------------------
1796
- // ColorPickerPopover (trigger button + popover panel)
1797
- //
1798
- // Built on Base UI's Popover primitive, which owns positioning (anchor
1799
- // tracking + collision flipping — the old version placed the panel at a
1800
- // captured rect and could overflow the viewport bottom), dismissal (outside
1801
- // press, focus-out, Escape only while focus is relevant), and focus
1802
- // management (focus moves into the panel on open and restores to the trigger
1803
- // on close). The panel morphs out of its trigger and back (lib/use-morph.ts),
1804
- // the same pattern as popover.tsx.
1805
- // ---------------------------------------------------------------------------
1806
-
1807
- const ColorPickerPopover = forwardRef<HTMLDivElement, ColorPickerPopoverProps>(
1808
- (
1809
- {
1810
- triggerLabel,
1811
- triggerLabelPosition = 'left',
1812
- triggerShowValue = true,
1813
- triggerShowRemove = false,
1814
- onTriggerRemove,
1815
- triggerClassName,
1816
- removeLabel = 'Remove color',
1817
- disabled = false,
1818
- readOnly = false,
1819
- rootClassName,
1820
- open: openProp,
1821
- defaultOpen = false,
1822
- onOpenChange,
1823
- size,
1824
- from,
1825
- effect,
1826
- hideSource,
1827
- tier,
1828
- ...pickerProps
1829
- },
1830
- ref,
1831
- ) => {
1832
- const isOpenControlled = openProp !== undefined
1833
- const [internalOpen, setInternalOpen] = useState(defaultOpen)
1834
- const locked = disabled || readOnly
1835
- const open = !locked && (isOpenControlled ? openProp : internalOpen)
1836
- const { origin, capture } = useMorphOrigin()
1837
- const morph = useMorph(open, origin, { from, effect, hideSource, tier })
1838
- const shape = useShape()
1839
- // Resolved with the override directly: this component's own hooks run
1840
- // outside the SizeProvider it renders, so the trigger can't read the pin
1841
- // from context. The portalled panel inherits it from the provider below
1842
- // (React context crosses portals).
1843
- const sizeClasses = useSize(size)
1844
- const compact = sizeClasses.variant === 'compact'
1845
- const substrate = useSurface()
1846
- const level = Math.min(substrate + 2, 8)
1847
-
1848
- const handleOpenChange = useCallback(
1849
- (next: boolean, details: { trigger?: Element; event?: Event }) => {
1850
- if (locked) return
1851
- if (next) capture(details)
1852
- if (!isOpenControlled) setInternalOpen(next)
1853
- onOpenChange?.(next)
1854
- },
1855
- [isOpenControlled, locked, onOpenChange, capture],
1856
- )
1857
-
1858
- const isControlled = pickerProps.value !== undefined
1859
- const [internalValue, setInternalValue] = useState(pickerProps.value ?? pickerProps.defaultValue ?? '#6B97FF')
1860
- const currentValue = isControlled ? (pickerProps.value as string) : internalValue
1861
-
1862
- const handleValueChange = useCallback(
1863
- (v: string, parsed: ParsedColor) => {
1864
- if (!isControlled) setInternalValue(v)
1865
- pickerProps.onValueChange?.(v, parsed)
1866
- },
1867
- [isControlled, pickerProps],
1868
- )
1869
-
1870
- const XIcon = useIcon('x')
1871
- const parsed = useMemo(() => parseColor(currentValue), [currentValue])
1872
- const swatchColor = parsed ? rgbToHexStr(parsed.r, parsed.g, parsed.b, parsed.a) : currentValue
1873
- const valueLabel = parsed
1874
- ? rgbToHexStr(parsed.r, parsed.g, parsed.b, 1).replace(/^#/, '').toUpperCase()
1875
- : currentValue
1876
-
1877
- // A size prop pins the whole compound (trigger + portalled panel — React
1878
- // context crosses portals) to one step of the ladder.
1879
- const root = (
1880
- <Popover.Root
1881
- open={open}
1882
- onOpenChange={handleOpenChange}
1883
- // Non-modal: the page keeps scrolling and the Positioner tracks the
1884
- // anchor, so the panel follows its trigger instead of detaching.
1885
- modal={false}
1886
- >
1887
- <div ref={ref} className={cn('inline-flex', rootClassName)}>
1888
- <Popover.Trigger
1889
- disabled={disabled}
1890
- tabIndex={readOnly ? -1 : undefined}
1891
- data-readonly={readOnly || undefined}
1892
- className={cn(
1893
- 'flex items-center border border-border bg-transparent hover:bg-hover transition-colors duration-fast outline-none focus-visible:ring-1 focus-visible:ring-focus-ring cursor-pointer',
1894
- 'disabled:opacity-50 disabled:pointer-events-none data-[readonly]:cursor-default data-[readonly]:hover:!bg-transparent',
1895
- sizeClasses.gap,
1896
- sizeClasses.control,
1897
- compact ? 'px-1.5' : 'px-2',
1898
- shape.input,
1899
- 'weight-medium',
1900
- triggerClassName,
1901
- )}
1902
- >
1903
- {triggerLabel && triggerLabelPosition === 'left' && (
1904
- <span className={cn('text-muted-foreground px-1 select-none', sizeClasses.text)}>{triggerLabel}</span>
1905
- )}
1906
- <ColorTile color={swatchColor} size={compact ? 16 : 20} />
1907
- {triggerShowValue && (
1908
- <span className={cn('text-foreground tabular-nums', sizeClasses.text)}>{valueLabel}</span>
1909
- )}
1910
- {triggerLabel && triggerLabelPosition === 'right' && (
1911
- <span className={cn('text-muted-foreground px-1 select-none', sizeClasses.text)}>{triggerLabel}</span>
1912
- )}
1913
- {triggerShowRemove && !locked && (
1914
- <span
1915
- role="button"
1916
- aria-label={removeLabel}
1917
- tabIndex={0}
1918
- onClick={e => {
1919
- e.stopPropagation()
1920
- onTriggerRemove?.()
1921
- }}
1922
- onKeyDown={e => {
1923
- if (e.key === 'Enter' || e.key === ' ') {
1924
- e.stopPropagation()
1925
- e.preventDefault()
1926
- onTriggerRemove?.()
1927
- }
1928
- }}
1929
- className="ml-1 text-muted-foreground hover:text-foreground cursor-pointer flex items-center"
1930
- >
1931
- <XIcon size={14} strokeWidth={1.5} />
1932
- </span>
1933
- )}
1934
- </Popover.Trigger>
1935
- <Popover.Portal>
1936
- <Popover.Positioner
1937
- side="bottom"
1938
- align="start"
1939
- sideOffset={opensInPlace({ effect, hideSource }) ? inPlaceOffset : 6}
1940
- className="z-50 outline-none"
1941
- >
1942
- <Popover.Popup ref={morph.popupRef} className="relative outline-none">
1943
- <ColorPickerPortalContainer value={morph.popup}>
1944
- <SurfaceProvider value={level}>
1945
- <MorphSurface
1946
- morph={morph}
1947
- bg={SURFACE_BG[level]}
1948
- shadow={SURFACE_SHADOW[3]}
1949
- radius={shape.container}
1950
- >
1951
- <PanelSurfaceContext.Provider value={false}>
1952
- <ColorPicker {...pickerProps} value={currentValue} onValueChange={handleValueChange} />
1953
- </PanelSurfaceContext.Provider>
1954
- </MorphSurface>
1955
- </SurfaceProvider>
1956
- </ColorPickerPortalContainer>
1957
- </Popover.Popup>
1958
- </Popover.Positioner>
1959
- </Popover.Portal>
1960
- </div>
1961
- </Popover.Root>
1962
- )
1963
-
1964
- return size ? <SizeProvider size={size}>{root}</SizeProvider> : root
1965
- },
1966
- )
1967
-
1968
- ColorPickerPopover.displayName = 'ColorPickerPopover'
1969
-
1970
- // Compound static: `<ColorPicker.Popover>` (typed by the ColorPickerComponent
1971
- // cast on the forwardRef above).
1972
- Object.assign(ColorPicker, { Popover: ColorPickerPopover })
1973
-
1974
- // ---------------------------------------------------------------------------
1975
- // Exports
1976
- // ---------------------------------------------------------------------------
1977
-
1978
- export type { ColorFormat, ColorPickerPopoverProps, ColorPickerProps, ColorSwatchProps, ParsedColor }
1979
- export { buildParsed, ColorPicker, ColorPickerPopover, ColorPickerPortalContainer, ColorSwatch, ColorTile, parseColor }
1980
-
1981
- export default ColorPicker