@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.
- package/README.md +66 -164
- package/dist/base.css +4 -141
- package/dist/blocks/sidebar-app/index.js +26 -40
- package/dist/components/AI/input-message/index.js +16 -23
- package/dist/components/AI/thinking-indicator/index.js +6 -9
- package/dist/components/accordion/index.d.ts +11 -39
- package/dist/components/accordion/index.js +13 -18
- package/dist/components/badge/index.d.ts +11 -18
- package/dist/components/badge/index.js +7 -12
- package/dist/components/button/index.d.ts +17 -53
- package/dist/components/button/index.js +6 -11
- package/dist/components/card/index.d.ts +9 -32
- package/dist/components/card/index.js +11 -17
- package/dist/components/copy/index.d.ts +8 -41
- package/dist/components/copy/index.js +9 -15
- package/dist/components/inputs/checkbox/index.d.ts +9 -28
- package/dist/components/inputs/checkbox/index.js +13 -18
- package/dist/components/inputs/color-picker/index.d.ts +14 -22
- package/dist/components/inputs/color-picker/index.js +18 -26
- package/dist/components/inputs/combobox/index.d.ts +20 -85
- package/dist/components/inputs/combobox/index.js +18 -27
- package/dist/components/inputs/date-time/calendar/index.d.ts +18 -24
- package/dist/components/inputs/date-time/calendar/index.js +11 -15
- package/dist/components/inputs/date-time/date-picker/index.d.ts +16 -105
- package/dist/components/inputs/date-time/date-picker/index.js +25 -35
- package/dist/components/inputs/date-time/date-range-picker/index.d.ts +12 -106
- package/dist/components/inputs/date-time/date-range-picker/index.js +24 -35
- package/dist/components/inputs/date-time/duration-picker/index.d.ts +13 -66
- package/dist/components/inputs/date-time/duration-picker/index.js +24 -33
- package/dist/components/inputs/date-time/month-picker/index.d.ts +10 -60
- package/dist/components/inputs/date-time/month-picker/index.js +23 -32
- package/dist/components/inputs/date-time/time-picker/index.d.ts +10 -58
- package/dist/components/inputs/date-time/time-picker/index.js +24 -33
- package/dist/components/inputs/input/index.d.ts +13 -95
- package/dist/components/inputs/input/index.js +9 -18
- package/dist/components/inputs/radio/index.d.ts +5 -20
- package/dist/components/inputs/radio/index.js +12 -16
- package/dist/components/inputs/search/index.d.ts +3 -3
- package/dist/components/inputs/search/index.js +18 -26
- package/dist/components/inputs/select/index.d.ts +23 -99
- package/dist/components/inputs/select/index.js +14 -29
- package/dist/components/inputs/slider/index.d.ts +0 -9
- package/dist/components/inputs/slider/index.js +5 -8
- package/dist/components/inputs/switch/index.d.ts +2 -8
- package/dist/components/inputs/switch/index.js +12 -15
- package/dist/components/inputs/textarea/index.d.ts +1 -20
- package/dist/components/inputs/textarea/index.js +6 -9
- package/dist/components/navigation/sidebar/index.d.ts +21 -44
- package/dist/components/navigation/sidebar/index.js +31 -42
- package/dist/components/navigation/tabs/index.d.ts +2 -8
- package/dist/components/navigation/tabs/index.js +10 -15
- package/dist/components/navigation/tabs-subtle/index.js +10 -13
- package/dist/components/overlays/alert-dialog/index.d.ts +16 -135
- package/dist/components/overlays/alert-dialog/index.js +13 -23
- package/dist/components/overlays/command/index.d.ts +20 -25
- package/dist/components/overlays/command/index.js +17 -25
- package/dist/components/overlays/context-menu/index.d.ts +17 -39
- package/dist/components/overlays/context-menu/index.js +18 -29
- package/dist/components/overlays/dialog/index.d.ts +16 -42
- package/dist/components/overlays/dialog/index.js +12 -20
- package/dist/components/overlays/dropdown/index.d.ts +34 -304
- package/dist/components/overlays/dropdown/index.js +17 -44
- package/dist/components/overlays/popover/index.d.ts +12 -38
- package/dist/components/overlays/popover/index.js +9 -13
- package/dist/components/overlays/preview-card/index.js +8 -12
- package/dist/components/overlays/sheet/index.d.ts +8 -25
- package/dist/components/overlays/sheet/index.js +13 -19
- package/dist/components/overlays/toast/index.d.ts +50 -209
- package/dist/components/overlays/toast/index.js +10 -23
- package/dist/components/overlays/tooltip/index.d.ts +21 -123
- package/dist/components/overlays/tooltip/index.js +6 -16
- package/dist/components/table/index.d.ts +8 -63
- package/dist/components/table/index.js +9 -20
- package/dist/index.d.ts +1048 -2999
- package/dist/index.js +82 -231
- package/dist/preset.d.ts +0 -22
- package/dist/preset.js +2 -24
- package/dist/primitives/code/index.js +5 -8
- package/dist/primitives/craft/index.js +6 -10
- package/dist/primitives/fluid-hover/index.js +8 -10
- package/dist/primitives/morph/index.js +10 -16
- package/dist/primitives/motion/index.js +8 -11
- package/dist/primitives/scroll-area/index.js +3 -5
- package/dist/primitives/sizes/index.js +4 -6
- package/dist/primitives/surface/index.js +4 -6
- package/dist/shared/{components-c7rwx0ap.js → components-0q0sqfh4.js} +49 -27
- package/dist/shared/components-0sn7ypjw.js +61 -0
- package/dist/shared/{components-vc864c51.js → components-11da9ajg.js} +5 -8
- package/dist/shared/components-1434p1mh.js +123 -0
- package/dist/shared/{components-c74gf16d.js → components-1z6tdadk.js} +24 -40
- package/dist/shared/{components-bae8sbjx.js → components-27nnvrwe.js} +14 -15
- package/dist/shared/{components-qh1cvj6p.js → components-2dxkv6zm.js} +7 -8
- package/dist/shared/{components-y0kfy2g4.js → components-2ecg8svx.js} +152 -234
- package/dist/shared/{components-c5w5zk0s.js → components-2ph6t61s.js} +34 -37
- package/dist/shared/{components-4j3t39dd.js → components-39jq9va1.js} +24 -29
- package/dist/shared/components-3ky44908.js +9 -0
- package/dist/shared/{components-syynwm1r.js → components-3mkepka5.js} +25 -28
- package/dist/shared/{components-wgtjt2af.js → components-4700p9kz.js} +22 -24
- package/dist/shared/{components-yhgq0qxx.js → components-59efxjd9.js} +12 -13
- package/dist/shared/{components-91zw3d9f.js → components-5vgpnxgy.js} +27 -51
- package/dist/shared/{components-kz5q11pp.js → components-5xc2j8jz.js} +18 -19
- package/dist/shared/{components-zkk3fmw8.js → components-6p8tfcd6.js} +29 -57
- package/dist/shared/{components-4nck8acq.js → components-7r3etddy.js} +54 -19
- package/dist/shared/{components-svkt577j.js → components-8snj490h.js} +32 -78
- package/dist/shared/{components-ctqmge8t.js → components-b0eac83j.js} +30 -9
- package/dist/shared/{components-9sttq78t.js → components-b2wyq2cq.js} +59 -41
- package/dist/shared/{components-pv1dyrtn.js → components-b3c5et5b.js} +38 -49
- package/dist/shared/{components-rrbpmfx4.js → components-c6s6b43g.js} +2 -3
- package/dist/shared/{components-pwayrkw9.js → components-chacnk61.js} +19 -19
- package/dist/shared/{components-71nbt03m.js → components-cv16mryv.js} +7 -26
- package/dist/shared/{components-hgv6tygd.js → components-d6xtjht3.js} +30 -35
- package/dist/shared/{components-z8fedth5.js → components-dk0gjr5a.js} +18 -19
- package/dist/shared/{components-cet3jpa0.js → components-fzrq6tr3.js} +34 -37
- package/dist/shared/{components-cnead5qm.js → components-gc5vtq2f.js} +30 -66
- package/dist/shared/components-gf5s6ap9.js +42 -0
- package/dist/shared/{components-0h78g3b5.js → components-gz8zf3ec.js} +55 -56
- package/dist/shared/{components-mattt5jp.js → components-h2ajmhwn.js} +35 -107
- package/dist/shared/{components-m06yq8wk.js → components-hqeq3w9q.js} +23 -38
- package/dist/shared/{components-7qx17na9.js → components-hw9yk32z.js} +24 -36
- package/dist/shared/components-hyq23phv.js +310 -0
- package/dist/shared/{components-9jmjnkqd.js → components-jbbmdr74.js} +22 -41
- package/dist/shared/{components-dcc02hs2.js → components-jjw69c4c.js} +4 -7
- package/dist/shared/{components-303jf96s.js → components-jthe1pwb.js} +420 -139
- package/dist/shared/{components-dtca4s6b.js → components-jwst3vtj.js} +76 -224
- package/dist/shared/{components-dc5q749x.js → components-kvr7f9hy.js} +10 -19
- package/dist/shared/{components-s8yqk2dc.js → components-kwa6mrf9.js} +11 -46
- package/dist/shared/{components-de99ykt7.js → components-men63rwj.js} +2 -3
- package/dist/shared/{components-qx27excf.js → components-mjthm1kf.js} +9 -13
- package/dist/shared/{components-1e64t8aj.js → components-n42c79bz.js} +22 -23
- package/dist/shared/{components-tz1fv35x.js → components-n7mjr357.js} +2 -3
- package/dist/shared/components-ng72k97e.js +128 -0
- package/dist/shared/{components-w2jqa6hx.js → components-pbmqctfa.js} +65 -169
- package/dist/shared/{components-px2zm7dc.js → components-q5xj5c16.js} +8 -9
- package/dist/shared/components-raknhppb.js +64 -0
- package/dist/shared/{components-2a7hcand.js → components-rr7a6ja9.js} +27 -59
- package/dist/shared/{components-005jt2nz.js → components-s083f38z.js} +34 -164
- package/dist/shared/components-s5ngg32c.js +267 -0
- package/dist/shared/components-sn1rxx1d.js +143 -0
- package/dist/shared/{components-r8eqs7gz.js → components-ta7shw28.js} +4 -4
- package/dist/shared/{components-9as3rf68.js → components-vc7vq5nz.js} +3 -6
- package/dist/shared/{components-ft3zdc79.js → components-vfbhxf5r.js} +5 -6
- package/dist/shared/components-vynjtrk6.js +19 -0
- package/dist/shared/{components-p0fm0bab.js → components-w1r67ag9.js} +149 -294
- package/dist/shared/{components-xd3emmy4.js → components-w90endve.js} +72 -71
- package/dist/shared/{components-t5pk5nmc.js → components-wvp1m52g.js} +102 -149
- package/dist/shared/components-wy9jpqny.js +146 -0
- package/dist/shared/{components-yxmxn2jp.js → components-y9y0g8er.js} +28 -52
- package/dist/shared/components-ypp01w3p.js +986 -0
- package/dist/shared/{components-czdvj1a8.js → components-zfnmn5d4.js} +36 -82
- package/dist/shared/{components-jqpefjsn.js → components-zrkzcayz.js} +6 -54
- package/dist/shared/{components-vf45j12c.js → components-zx6b6yby.js} +18 -4
- package/dist/styles.css +32 -320
- package/dist/tailwind.css +0 -16
- package/dist/tokens.css +0 -67
- package/package.json +4 -227
- package/DESIGN.md +0 -623
- package/blocks/sidebar-app/examples.mdx +0 -33
- package/blocks/sidebar-app/index.tsx +0 -218
- package/blocks/sidebar-app/inset-topbar.tsx +0 -25
- package/blocks/sidebar-app/menu-grid.ts +0 -26
- package/blocks/sidebar-app/nav-data.ts +0 -36
- package/blocks/sidebar-app/search-field.tsx +0 -48
- package/blocks/sidebar-app/user-footer.tsx +0 -67
- package/blocks/sidebar-app/workspace-header.tsx +0 -129
- package/components/AI/input-message/examples.mdx +0 -130
- package/components/AI/input-message/file-thumbnail.tsx +0 -173
- package/components/AI/input-message/index.tsx +0 -1385
- package/components/AI/thinking-indicator/examples.mdx +0 -27
- package/components/AI/thinking-indicator/index.tsx +0 -210
- package/components/accordion/examples.mdx +0 -233
- package/components/accordion/index.tsx +0 -1071
- package/components/avatar/examples.mdx +0 -95
- package/components/avatar/index.tsx +0 -129
- package/components/badge/change-demo.tsx +0 -29
- package/components/badge/examples.mdx +0 -124
- package/components/badge/index.tsx +0 -225
- package/components/badge/variants.ts +0 -73
- package/components/button/examples.mdx +0 -168
- package/components/button/index.tsx +0 -424
- package/components/button/loading-demo.tsx +0 -25
- package/components/button/variants.ts +0 -168
- package/components/card/examples.mdx +0 -251
- package/components/card/index.tsx +0 -1070
- package/components/copy/examples.mdx +0 -125
- package/components/copy/index.tsx +0 -662
- package/components/inputs/checkbox/examples.mdx +0 -87
- package/components/inputs/checkbox/index.tsx +0 -611
- package/components/inputs/checkbox/select-all-demo.tsx +0 -48
- package/components/inputs/color-picker/examples.mdx +0 -111
- package/components/inputs/color-picker/index.tsx +0 -1981
- package/components/inputs/color-picker/variants.ts +0 -401
- package/components/inputs/combobox/examples.mdx +0 -366
- package/components/inputs/combobox/index.tsx +0 -1527
- package/components/inputs/combobox/remote-demo.tsx +0 -105
- package/components/inputs/date-time/calendar/examples.mdx +0 -45
- package/components/inputs/date-time/calendar/index.tsx +0 -370
- package/components/inputs/date-time/calendar/use-month-window.ts +0 -173
- package/components/inputs/date-time/date-picker/examples.mdx +0 -243
- package/components/inputs/date-time/date-picker/index.tsx +0 -476
- package/components/inputs/date-time/date-picker/masked-date-input.tsx +0 -348
- package/components/inputs/date-time/date-range-picker/examples.mdx +0 -93
- package/components/inputs/date-time/date-range-picker/index.tsx +0 -525
- package/components/inputs/date-time/duration-picker/examples.mdx +0 -40
- package/components/inputs/date-time/duration-picker/index.tsx +0 -253
- package/components/inputs/date-time/month-picker/examples.mdx +0 -45
- package/components/inputs/date-time/month-picker/index.tsx +0 -254
- package/components/inputs/date-time/picker-shell.tsx +0 -581
- package/components/inputs/date-time/time-columns.tsx +0 -94
- package/components/inputs/date-time/time-picker/examples.mdx +0 -57
- package/components/inputs/date-time/time-picker/index.tsx +0 -227
- package/components/inputs/field/examples.mdx +0 -117
- package/components/inputs/field/index.tsx +0 -268
- package/components/inputs/file-dropzone/examples.mdx +0 -83
- package/components/inputs/file-dropzone/file-row.tsx +0 -201
- package/components/inputs/file-dropzone/index.tsx +0 -316
- package/components/inputs/file-dropzone/upload-demo.tsx +0 -67
- package/components/inputs/file-dropzone/variants.ts +0 -94
- package/components/inputs/input/examples.mdx +0 -166
- package/components/inputs/input/index.tsx +0 -494
- package/components/inputs/input/password-demo.tsx +0 -30
- package/components/inputs/label/examples.mdx +0 -67
- package/components/inputs/label/index.tsx +0 -85
- package/components/inputs/number-input/examples.mdx +0 -69
- package/components/inputs/number-input/index.tsx +0 -221
- package/components/inputs/radio/examples.mdx +0 -77
- package/components/inputs/radio/index.tsx +0 -636
- package/components/inputs/search/examples.mdx +0 -74
- package/components/inputs/search/index.tsx +0 -333
- package/components/inputs/select/examples.mdx +0 -296
- package/components/inputs/select/index.tsx +0 -1044
- package/components/inputs/select/variants.ts +0 -32
- package/components/inputs/slider/examples.mdx +0 -153
- package/components/inputs/slider/index.tsx +0 -1867
- package/components/inputs/switch/examples.mdx +0 -96
- package/components/inputs/switch/index.tsx +0 -504
- package/components/inputs/textarea/examples.mdx +0 -126
- package/components/inputs/textarea/index.tsx +0 -207
- package/components/navigation/navigation-menu/examples.mdx +0 -136
- package/components/navigation/navigation-menu/index.tsx +0 -704
- package/components/navigation/pagination/examples.mdx +0 -74
- package/components/navigation/pagination/index.tsx +0 -401
- package/components/navigation/sidebar/examples.mdx +0 -325
- package/components/navigation/sidebar/index.tsx +0 -453
- package/components/navigation/sidebar/sidebar-core.tsx +0 -1408
- package/components/navigation/sidebar/sidebar-menu.tsx +0 -1495
- package/components/navigation/sidebar/sidebar.css +0 -74
- package/components/navigation/sidebar/variants.ts +0 -55
- package/components/navigation/tabs/examples.mdx +0 -89
- package/components/navigation/tabs/index.tsx +0 -494
- package/components/navigation/tabs-subtle/examples.mdx +0 -49
- package/components/navigation/tabs-subtle/index.tsx +0 -512
- package/components/overlays/alert-dialog/examples.mdx +0 -123
- package/components/overlays/alert-dialog/index.tsx +0 -276
- package/components/overlays/alert-dialog/pending-demo.tsx +0 -36
- package/components/overlays/command/examples.mdx +0 -113
- package/components/overlays/command/index.tsx +0 -425
- package/components/overlays/context-menu/examples.mdx +0 -56
- package/components/overlays/context-menu/index.tsx +0 -279
- package/components/overlays/dialog/examples.mdx +0 -165
- package/components/overlays/dialog/index.tsx +0 -451
- package/components/overlays/dropdown/dropdown-search.tsx +0 -395
- package/components/overlays/dropdown/examples.mdx +0 -444
- package/components/overlays/dropdown/index.tsx +0 -1241
- package/components/overlays/dropdown/menu-item.tsx +0 -510
- package/components/overlays/dropdown/menu-parts.tsx +0 -725
- package/components/overlays/popover/anchored-demo.tsx +0 -59
- package/components/overlays/popover/examples.mdx +0 -203
- package/components/overlays/popover/index.tsx +0 -317
- package/components/overlays/preview-card/examples.mdx +0 -61
- package/components/overlays/preview-card/index.tsx +0 -204
- package/components/overlays/sheet/examples.mdx +0 -101
- package/components/overlays/sheet/index.tsx +0 -307
- package/components/overlays/toast/examples.mdx +0 -85
- package/components/overlays/toast/global-demo.tsx +0 -72
- package/components/overlays/toast/index.tsx +0 -544
- package/components/overlays/toast/manager.ts +0 -193
- package/components/overlays/tooltip/examples.mdx +0 -106
- package/components/overlays/tooltip/index.tsx +0 -539
- package/components/progress/examples.mdx +0 -59
- package/components/progress/index.tsx +0 -174
- package/components/progress/progress.css +0 -49
- package/components/progress/step-demo.tsx +0 -27
- package/components/table/examples.mdx +0 -188
- package/components/table/index.tsx +0 -535
- package/components/table/sort-demo.tsx +0 -61
- package/dist/components/avatar/index.d.ts +0 -46
- package/dist/components/avatar/index.js +0 -19
- package/dist/components/badge/variants.d.ts +0 -33
- package/dist/components/badge/variants.js +0 -10
- package/dist/components/button/variants.d.ts +0 -39
- package/dist/components/button/variants.js +0 -8
- package/dist/components/inputs/color-picker/variants.d.ts +0 -61
- package/dist/components/inputs/color-picker/variants.js +0 -28
- package/dist/components/inputs/field/index.d.ts +0 -116
- package/dist/components/inputs/field/index.js +0 -29
- package/dist/components/inputs/file-dropzone/index.d.ts +0 -167
- package/dist/components/inputs/file-dropzone/index.js +0 -33
- package/dist/components/inputs/file-dropzone/variants.d.ts +0 -35
- package/dist/components/inputs/file-dropzone/variants.js +0 -12
- package/dist/components/inputs/label/index.d.ts +0 -32
- package/dist/components/inputs/label/index.js +0 -11
- package/dist/components/inputs/number-input/index.d.ts +0 -87
- package/dist/components/inputs/number-input/index.js +0 -21
- package/dist/components/inputs/select/variants.d.ts +0 -5
- package/dist/components/inputs/select/variants.js +0 -6
- package/dist/components/navigation/navigation-menu/index.d.ts +0 -126
- package/dist/components/navigation/navigation-menu/index.js +0 -41
- package/dist/components/navigation/pagination/index.d.ts +0 -75
- package/dist/components/navigation/pagination/index.js +0 -41
- package/dist/components/navigation/sidebar/variants.d.ts +0 -25
- package/dist/components/navigation/sidebar/variants.js +0 -28
- package/dist/components/progress/index.d.ts +0 -56
- package/dist/components/progress/index.js +0 -15
- package/dist/docs/index.d.ts +0 -83
- package/dist/docs/index.js +0 -288
- package/dist/docs/vite.d.ts +0 -31
- package/dist/docs/vite.js +0 -45
- package/dist/lib/date-locale.d.ts +0 -26
- package/dist/lib/date-locale.js +0 -24
- package/dist/lib/field-classes.d.ts +0 -149
- package/dist/lib/field-classes.js +0 -41
- package/dist/lib/field-variants.d.ts +0 -47
- package/dist/lib/field-variants.js +0 -26
- package/dist/lib/fluid-hover-highlight.d.ts +0 -112
- package/dist/lib/fluid-hover-highlight.js +0 -17
- package/dist/lib/focus-ring.d.ts +0 -18
- package/dist/lib/focus-ring.js +0 -11
- package/dist/lib/goo-indicator.d.ts +0 -46
- package/dist/lib/goo-indicator.js +0 -14
- package/dist/lib/icon-context.d.ts +0 -30
- package/dist/lib/icon-context.js +0 -12
- package/dist/lib/inert.d.ts +0 -9
- package/dist/lib/inert.js +0 -8
- package/dist/lib/morph-layers.d.ts +0 -93
- package/dist/lib/morph-layers.js +0 -13
- package/dist/lib/morph-part.d.ts +0 -21
- package/dist/lib/morph-part.js +0 -12
- package/dist/lib/popup.d.ts +0 -20
- package/dist/lib/popup.js +0 -12
- package/dist/lib/reduced-motion.d.ts +0 -9
- package/dist/lib/reduced-motion.js +0 -6
- package/dist/lib/row-index.d.ts +0 -10
- package/dist/lib/row-index.js +0 -8
- package/dist/lib/row-order.d.ts +0 -28
- package/dist/lib/row-order.js +0 -10
- package/dist/lib/scroll-column.d.ts +0 -31
- package/dist/lib/scroll-column.js +0 -19
- package/dist/lib/shape-context.d.ts +0 -30
- package/dist/lib/shape-context.js +0 -13
- package/dist/lib/shape-radius.d.ts +0 -19
- package/dist/lib/shape-radius.js +0 -6
- package/dist/lib/size-context.d.ts +0 -94
- package/dist/lib/size-context.js +0 -20
- package/dist/lib/size-scale.d.ts +0 -79
- package/dist/lib/size-scale.js +0 -8
- package/dist/lib/slot.d.ts +0 -39
- package/dist/lib/slot.js +0 -14
- package/dist/lib/springs.d.ts +0 -44
- package/dist/lib/springs.js +0 -8
- package/dist/lib/surface-classes.d.ts +0 -19
- package/dist/lib/surface-classes.js +0 -10
- package/dist/lib/surface-context.d.ts +0 -9
- package/dist/lib/surface-context.js +0 -8
- package/dist/lib/use-controllable-state.d.ts +0 -2
- package/dist/lib/use-controllable-state.js +0 -6
- package/dist/lib/use-fluid-hover.d.ts +0 -138
- package/dist/lib/use-fluid-hover.js +0 -14
- package/dist/lib/use-is-mobile.d.ts +0 -2
- package/dist/lib/use-is-mobile.js +0 -6
- package/dist/lib/use-keyboard-nav-gate.d.ts +0 -20
- package/dist/lib/use-keyboard-nav-gate.js +0 -7
- package/dist/lib/use-merge-split.d.ts +0 -47
- package/dist/lib/use-merge-split.js +0 -15
- package/dist/lib/use-morph.d.ts +0 -101
- package/dist/lib/use-morph.js +0 -20
- package/dist/lib/use-touch-primary.d.ts +0 -2
- package/dist/lib/use-touch-primary.js +0 -6
- package/dist/lib/utils.d.ts +0 -3
- package/dist/lib/utils.js +0 -6
- package/dist/primitives/separator/index.d.ts +0 -25
- package/dist/primitives/separator/index.js +0 -9
- package/dist/primitives/skeleton/index.d.ts +0 -35
- package/dist/primitives/skeleton/index.js +0 -13
- package/dist/shared/components-01w9qcfa.js +0 -277
- package/dist/shared/components-124epb7f.js +0 -53
- package/dist/shared/components-26snnbs9.js +0 -322
- package/dist/shared/components-2krsc300.js +0 -254
- package/dist/shared/components-2qbqqpgq.js +0 -1545
- package/dist/shared/components-34txk9rj.js +0 -35
- package/dist/shared/components-3h92d94m.js +0 -23
- package/dist/shared/components-4vfm04wq.js +0 -82
- package/dist/shared/components-54nq988h.js +0 -123
- package/dist/shared/components-7s7pykk5.js +0 -281
- package/dist/shared/components-868v7scv.js +0 -399
- package/dist/shared/components-8jrk5eye.js +0 -448
- package/dist/shared/components-adkarj8n.js +0 -35
- package/dist/shared/components-b1e239g3.js +0 -28
- package/dist/shared/components-b94t6qtj.js +0 -143
- package/dist/shared/components-bsqynhaj.js +0 -179
- package/dist/shared/components-c9yr4ed1.js +0 -44
- package/dist/shared/components-d3shbwqq.js +0 -95
- package/dist/shared/components-dhgk6mbt.js +0 -16
- package/dist/shared/components-drxrtnp2.js +0 -25
- package/dist/shared/components-f7h57m6t.js +0 -59
- package/dist/shared/components-g3eafs70.js +0 -337
- package/dist/shared/components-gx4h5rbb.js +0 -40
- package/dist/shared/components-gyt7my41.js +0 -328
- package/dist/shared/components-h6p86be3.js +0 -19
- package/dist/shared/components-h83az0bz.js +0 -33
- package/dist/shared/components-hdzrqqpx.js +0 -55
- package/dist/shared/components-krtx3m5b.js +0 -84
- package/dist/shared/components-kswxnm3h.js +0 -24
- package/dist/shared/components-mhza2wdn.js +0 -482
- package/dist/shared/components-mrbaxt8q.js +0 -62
- package/dist/shared/components-pt5ftm4p.js +0 -38
- package/dist/shared/components-rtadhd9j.js +0 -64
- package/dist/shared/components-tv7b8tet.js +0 -271
- package/dist/shared/components-tz4gaf1v.js +0 -23
- package/dist/shared/components-wbbckbw8.js +0 -34
- package/dist/shared/components-wknae3m4.js +0 -298
- package/dist/shared/components-xdqmt6ed.js +0 -20
- package/dist/shared/components-xjkdvnbg.js +0 -107
- package/dist/shared/components-z3v0n0de.js +0 -11
- package/dist/tokens/colors.css +0 -230
- package/dist/tokens/motion.css +0 -40
- package/dist/tokens/radius.css +0 -10
- package/dist/tokens/spacing.css +0 -9
- package/dist/tokens/typography.css +0 -119
- package/docs/chrome.css +0 -120
- package/docs/chrome.tsx +0 -283
- package/docs/index.ts +0 -9
- package/docs/vite.ts +0 -85
- package/index.ts +0 -60
- package/lib/date-locale.ts +0 -195
- package/lib/field-classes.ts +0 -121
- package/lib/field-variants.ts +0 -117
- package/lib/fluid-hover-highlight.tsx +0 -139
- package/lib/focus-ring.tsx +0 -46
- package/lib/goo-indicator.tsx +0 -261
- package/lib/icon-context.tsx +0 -310
- package/lib/inert.ts +0 -22
- package/lib/morph-layers.tsx +0 -102
- package/lib/morph-part.tsx +0 -49
- package/lib/popup.ts +0 -52
- package/lib/reduced-motion.ts +0 -19
- package/lib/row-index.tsx +0 -29
- package/lib/row-order.tsx +0 -126
- package/lib/scroll-column.tsx +0 -273
- package/lib/shape-context.tsx +0 -138
- package/lib/shape-radius.ts +0 -67
- package/lib/size-context.tsx +0 -66
- package/lib/size-scale.ts +0 -115
- package/lib/slot.ts +0 -115
- package/lib/springs.ts +0 -62
- package/lib/surface-classes.ts +0 -58
- package/lib/surface-context.tsx +0 -21
- package/lib/use-controllable-state.ts +0 -44
- package/lib/use-fluid-hover.ts +0 -541
- package/lib/use-is-mobile.ts +0 -25
- package/lib/use-keyboard-nav-gate.ts +0 -45
- package/lib/use-merge-split.tsx +0 -209
- package/lib/use-morph.ts +0 -575
- package/lib/use-touch-primary.tsx +0 -43
- package/lib/utils.ts +0 -37
- package/preset.ts +0 -199
- package/primitives/code/examples.mdx +0 -27
- package/primitives/code/index.tsx +0 -80
- package/primitives/craft/demos.tsx +0 -173
- package/primitives/craft/examples.mdx +0 -142
- package/primitives/craft/index.tsx +0 -76
- package/primitives/fluid-hover/examples.mdx +0 -84
- package/primitives/fluid-hover/index.tsx +0 -316
- package/primitives/morph/examples.mdx +0 -139
- package/primitives/morph/index.tsx +0 -192
- package/primitives/motion/examples.mdx +0 -100
- package/primitives/motion/index.tsx +0 -160
- package/primitives/scroll-area/examples.mdx +0 -58
- package/primitives/scroll-area/index.tsx +0 -204
- package/primitives/separator/examples.mdx +0 -30
- package/primitives/separator/index.tsx +0 -53
- package/primitives/sizes/examples.mdx +0 -167
- package/primitives/sizes/index.tsx +0 -89
- package/primitives/skeleton/examples.mdx +0 -51
- package/primitives/skeleton/index.tsx +0 -72
- package/primitives/skeleton/skeleton.css +0 -15
- package/primitives/surface/examples.mdx +0 -173
- package/primitives/surface/index.tsx +0 -109
- package/primitives/surface/surface.css +0 -176
- /package/dist/shared/{components-3ey0hzpb.js → components-1v7rxytx.js} +0 -0
- /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
|