@justanarthur/modo-ui 0.5.0 → 0.6.0-canary.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +608 -0
- package/README.md +61 -39
- package/blocks/sidebar-app/examples.mdx +33 -0
- package/blocks/sidebar-app/index.tsx +218 -0
- package/blocks/sidebar-app/inset-topbar.tsx +25 -0
- package/blocks/sidebar-app/menu-grid.ts +26 -0
- package/blocks/sidebar-app/nav-data.ts +36 -0
- package/blocks/sidebar-app/search-field.tsx +48 -0
- package/blocks/sidebar-app/user-footer.tsx +67 -0
- package/blocks/sidebar-app/workspace-header.tsx +129 -0
- package/components/AI/input-message/examples.mdx +124 -0
- package/components/AI/input-message/file-thumbnail.tsx +173 -0
- package/components/AI/input-message/index.tsx +1383 -0
- package/components/AI/thinking-indicator/examples.mdx +27 -0
- package/components/AI/thinking-indicator/index.tsx +210 -0
- package/components/accordion/examples.mdx +148 -0
- package/components/accordion/index.tsx +1034 -0
- package/components/avatar/examples.mdx +95 -0
- package/components/avatar/index.tsx +129 -0
- package/components/badge/change-demo.tsx +29 -0
- package/components/badge/examples.mdx +120 -0
- package/components/badge/index.tsx +225 -0
- package/components/badge/variants.ts +73 -0
- package/components/button/examples.mdx +164 -0
- package/components/button/index.tsx +416 -0
- package/components/button/loading-demo.tsx +25 -0
- package/components/button/variants.ts +166 -0
- package/components/card/examples.mdx +212 -0
- package/components/card/index.tsx +1037 -0
- package/components/copy/examples.mdx +87 -0
- package/components/copy/index.tsx +482 -0
- package/components/inputs/checkbox/examples.mdx +83 -0
- package/components/inputs/checkbox/index.tsx +611 -0
- package/components/inputs/checkbox/select-all-demo.tsx +48 -0
- package/components/inputs/color-picker/examples.mdx +72 -0
- package/components/inputs/color-picker/index.tsx +1955 -0
- package/components/inputs/color-picker/variants.ts +401 -0
- package/components/inputs/combobox/examples.mdx +273 -0
- package/components/inputs/combobox/index.tsx +1436 -0
- package/components/inputs/combobox/remote-demo.tsx +101 -0
- package/components/inputs/date-time/calendar/examples.mdx +34 -0
- package/components/inputs/date-time/calendar/index.tsx +351 -0
- package/components/inputs/date-time/calendar/use-month-window.ts +163 -0
- package/components/inputs/date-time/date-picker/examples.mdx +166 -0
- package/components/inputs/date-time/date-picker/index.tsx +385 -0
- package/components/inputs/date-time/date-picker/masked-date-input.tsx +336 -0
- package/components/inputs/date-time/date-range-picker/examples.mdx +29 -0
- package/components/inputs/date-time/date-range-picker/index.tsx +217 -0
- package/components/inputs/date-time/duration-picker/examples.mdx +16 -0
- package/components/inputs/date-time/duration-picker/index.tsx +189 -0
- package/components/inputs/date-time/month-picker/examples.mdx +16 -0
- package/components/inputs/date-time/month-picker/index.tsx +190 -0
- package/components/inputs/date-time/picker-shell.tsx +395 -0
- package/components/inputs/date-time/time-columns.tsx +91 -0
- package/components/inputs/date-time/time-picker/examples.mdx +26 -0
- package/components/inputs/date-time/time-picker/index.tsx +166 -0
- package/components/inputs/field/examples.mdx +113 -0
- package/components/inputs/field/index.tsx +267 -0
- package/components/inputs/file-dropzone/examples.mdx +73 -0
- package/components/inputs/file-dropzone/file-row.tsx +201 -0
- package/components/inputs/file-dropzone/index.tsx +296 -0
- package/components/inputs/file-dropzone/upload-demo.tsx +67 -0
- package/components/inputs/file-dropzone/variants.ts +94 -0
- package/components/inputs/input/examples.mdx +135 -0
- package/components/inputs/input/index.tsx +494 -0
- package/components/inputs/input/password-demo.tsx +30 -0
- package/components/inputs/label/examples.mdx +67 -0
- package/components/inputs/label/index.tsx +85 -0
- package/components/inputs/number-input/examples.mdx +56 -0
- package/components/inputs/number-input/index.tsx +214 -0
- package/components/inputs/radio/examples.mdx +77 -0
- package/components/inputs/radio/index.tsx +636 -0
- package/components/inputs/search/examples.mdx +74 -0
- package/components/inputs/search/index.tsx +333 -0
- package/components/inputs/select/examples.mdx +218 -0
- package/components/inputs/select/index.tsx +862 -0
- package/components/inputs/select/variants.ts +32 -0
- package/components/inputs/slider/examples.mdx +127 -0
- package/components/inputs/slider/index.tsx +1725 -0
- package/components/inputs/switch/examples.mdx +94 -0
- package/components/inputs/switch/index.tsx +504 -0
- package/components/inputs/textarea/examples.mdx +88 -0
- package/components/inputs/textarea/index.tsx +161 -0
- package/components/navigation/navigation-menu/examples.mdx +109 -0
- package/components/navigation/navigation-menu/index.tsx +613 -0
- package/components/navigation/pagination/examples.mdx +64 -0
- package/components/navigation/pagination/index.tsx +395 -0
- package/components/navigation/sidebar/examples.mdx +319 -0
- package/components/navigation/sidebar/index.tsx +453 -0
- package/components/navigation/sidebar/sidebar-core.tsx +1408 -0
- package/components/navigation/sidebar/sidebar-menu.tsx +1495 -0
- package/components/navigation/sidebar/sidebar.css +74 -0
- package/components/navigation/sidebar/variants.ts +55 -0
- package/components/navigation/tabs/examples.mdx +89 -0
- package/components/navigation/tabs/index.tsx +494 -0
- package/components/navigation/tabs-subtle/examples.mdx +49 -0
- package/components/navigation/tabs-subtle/index.tsx +512 -0
- package/components/overlays/alert-dialog/examples.mdx +123 -0
- package/components/overlays/alert-dialog/index.tsx +258 -0
- package/components/overlays/alert-dialog/pending-demo.tsx +36 -0
- package/components/overlays/command/examples.mdx +113 -0
- package/components/overlays/command/index.tsx +416 -0
- package/components/overlays/context-menu/examples.mdx +56 -0
- package/components/overlays/context-menu/index.tsx +279 -0
- package/components/overlays/dialog/examples.mdx +144 -0
- package/components/overlays/dialog/index.tsx +366 -0
- package/components/overlays/dropdown/dropdown-search.tsx +395 -0
- package/components/overlays/dropdown/examples.mdx +294 -0
- package/components/overlays/dropdown/index.tsx +1168 -0
- package/components/overlays/dropdown/menu-item.tsx +464 -0
- package/components/overlays/dropdown/menu-parts.tsx +660 -0
- package/components/overlays/popover/anchored-demo.tsx +59 -0
- package/components/overlays/popover/examples.mdx +180 -0
- package/components/overlays/popover/index.tsx +315 -0
- package/components/overlays/preview-card/examples.mdx +61 -0
- package/components/overlays/preview-card/index.tsx +204 -0
- package/components/overlays/sheet/examples.mdx +89 -0
- package/components/overlays/sheet/index.tsx +247 -0
- package/components/overlays/toast/examples.mdx +68 -0
- package/components/overlays/toast/global-demo.tsx +72 -0
- package/components/overlays/toast/index.tsx +474 -0
- package/components/overlays/toast/manager.ts +193 -0
- package/components/overlays/tooltip/examples.mdx +106 -0
- package/components/overlays/tooltip/index.tsx +519 -0
- package/components/progress/examples.mdx +59 -0
- package/components/progress/index.tsx +174 -0
- package/components/progress/progress.css +49 -0
- package/components/progress/step-demo.tsx +27 -0
- package/components/table/examples.mdx +184 -0
- package/components/table/index.tsx +510 -0
- package/components/table/sort-demo.tsx +61 -0
- package/dist/base.css +100 -0
- package/dist/blocks/sidebar-app/index.js +39 -26
- package/dist/components/AI/input-message/index.js +22 -16
- package/dist/components/AI/thinking-indicator/index.js +9 -6
- package/dist/components/accordion/index.js +16 -13
- package/dist/components/avatar/index.d.ts +46 -0
- package/dist/components/avatar/index.js +18 -0
- package/dist/components/badge/index.d.ts +18 -11
- package/dist/components/badge/index.js +11 -7
- package/dist/components/badge/variants.d.ts +33 -0
- package/dist/components/badge/variants.js +10 -0
- package/dist/components/button/index.d.ts +53 -17
- package/dist/components/button/index.js +10 -6
- package/dist/components/button/variants.d.ts +39 -0
- package/dist/components/button/variants.js +8 -0
- package/dist/components/card/index.d.ts +16 -3
- package/dist/components/card/index.js +16 -11
- package/dist/components/copy/index.js +13 -9
- package/dist/components/inputs/checkbox/index.d.ts +28 -9
- package/dist/components/inputs/checkbox/index.js +17 -13
- package/dist/components/inputs/color-picker/index.d.ts +13 -13
- package/dist/components/inputs/color-picker/index.js +25 -18
- package/dist/components/inputs/color-picker/variants.d.ts +61 -0
- package/dist/components/inputs/color-picker/variants.js +28 -0
- package/dist/components/inputs/combobox/index.d.ts +60 -17
- package/dist/components/inputs/combobox/index.js +26 -18
- package/dist/components/inputs/date-time/calendar/index.js +14 -11
- package/dist/components/inputs/date-time/date-picker/index.d.ts +53 -11
- package/dist/components/inputs/date-time/date-picker/index.js +33 -25
- package/dist/components/inputs/date-time/date-range-picker/index.d.ts +3 -3
- package/dist/components/inputs/date-time/date-range-picker/index.js +32 -24
- package/dist/components/inputs/date-time/duration-picker/index.d.ts +3 -3
- package/dist/components/inputs/date-time/duration-picker/index.js +32 -24
- package/dist/components/inputs/date-time/month-picker/index.d.ts +3 -3
- package/dist/components/inputs/date-time/month-picker/index.js +31 -23
- package/dist/components/inputs/date-time/time-picker/index.d.ts +3 -3
- package/dist/components/inputs/date-time/time-picker/index.js +32 -24
- package/dist/components/inputs/field/index.d.ts +116 -0
- package/dist/components/inputs/field/index.js +28 -0
- package/dist/components/inputs/file-dropzone/index.d.ts +165 -0
- package/dist/components/inputs/file-dropzone/index.js +32 -0
- package/dist/components/inputs/file-dropzone/variants.d.ts +35 -0
- package/dist/components/inputs/file-dropzone/variants.js +12 -0
- package/dist/components/inputs/input/index.d.ts +95 -13
- package/dist/components/inputs/input/index.js +17 -9
- package/dist/components/inputs/label/index.d.ts +32 -0
- package/dist/components/inputs/label/index.js +11 -0
- package/dist/components/inputs/number-input/index.d.ts +84 -0
- package/dist/components/inputs/number-input/index.js +20 -0
- package/dist/components/inputs/radio/index.d.ts +20 -5
- package/dist/components/inputs/radio/index.js +15 -12
- package/dist/components/inputs/search/index.js +25 -18
- package/dist/components/inputs/select/index.d.ts +41 -11
- package/dist/components/inputs/select/index.js +27 -14
- package/dist/components/inputs/select/variants.d.ts +5 -0
- package/dist/components/inputs/select/variants.js +6 -0
- package/dist/components/inputs/slider/index.js +7 -5
- package/dist/components/inputs/switch/index.d.ts +8 -2
- package/dist/components/inputs/switch/index.js +14 -12
- package/dist/components/inputs/textarea/index.d.ts +4 -0
- package/dist/components/inputs/textarea/index.js +8 -6
- package/dist/components/navigation/navigation-menu/index.d.ts +114 -0
- package/dist/components/navigation/navigation-menu/index.js +40 -0
- package/dist/components/navigation/pagination/index.d.ts +73 -0
- package/dist/components/navigation/pagination/index.js +39 -0
- package/dist/components/navigation/sidebar/index.d.ts +44 -21
- package/dist/components/navigation/sidebar/index.js +41 -31
- package/dist/components/navigation/sidebar/variants.d.ts +25 -0
- package/dist/components/navigation/sidebar/variants.js +28 -0
- package/dist/components/navigation/tabs/index.d.ts +8 -2
- package/dist/components/navigation/tabs/index.js +14 -10
- package/dist/components/navigation/tabs-subtle/index.js +12 -10
- package/dist/components/overlays/alert-dialog/index.d.ts +121 -15
- package/dist/components/overlays/alert-dialog/index.js +22 -13
- package/dist/components/overlays/command/index.js +24 -17
- package/dist/components/overlays/context-menu/index.d.ts +33 -17
- package/dist/components/overlays/context-menu/index.js +27 -18
- package/dist/components/overlays/dialog/index.js +17 -12
- package/dist/components/overlays/dropdown/index.d.ts +257 -31
- package/dist/components/overlays/dropdown/index.js +42 -17
- package/dist/components/overlays/popover/index.d.ts +31 -9
- package/dist/components/overlays/popover/index.js +12 -9
- package/dist/components/overlays/preview-card/index.js +11 -8
- package/dist/components/overlays/sheet/index.js +18 -13
- package/dist/components/overlays/toast/index.d.ts +123 -47
- package/dist/components/overlays/toast/index.js +18 -10
- package/dist/components/overlays/tooltip/index.d.ts +113 -21
- package/dist/components/overlays/tooltip/index.js +15 -6
- package/dist/components/progress/index.d.ts +56 -0
- package/dist/components/progress/index.js +15 -0
- package/dist/components/table/index.d.ts +63 -8
- package/dist/components/table/index.js +19 -9
- package/dist/index.d.ts +2201 -736
- package/dist/index.js +223 -82
- package/dist/lib/date-locale.d.ts +14 -0
- package/dist/lib/date-locale.js +16 -0
- package/dist/lib/field-classes.d.ts +144 -0
- package/dist/lib/field-classes.js +40 -0
- package/dist/lib/field-variants.d.ts +47 -0
- package/dist/lib/field-variants.js +26 -0
- package/dist/lib/fluid-hover-highlight.d.ts +112 -0
- package/dist/lib/fluid-hover-highlight.js +17 -0
- package/dist/lib/focus-ring.d.ts +18 -0
- package/dist/lib/focus-ring.js +10 -0
- package/dist/lib/goo-indicator.d.ts +46 -0
- package/dist/lib/goo-indicator.js +14 -0
- package/dist/lib/icon-context.d.ts +30 -0
- package/dist/lib/icon-context.js +12 -0
- package/dist/lib/inert.d.ts +9 -0
- package/dist/lib/inert.js +8 -0
- package/dist/lib/morph-layers.d.ts +93 -0
- package/dist/lib/morph-layers.js +13 -0
- package/dist/lib/morph-part.d.ts +21 -0
- package/dist/lib/morph-part.js +12 -0
- package/dist/lib/popup.d.ts +20 -0
- package/dist/lib/popup.js +12 -0
- package/dist/lib/reduced-motion.d.ts +9 -0
- package/dist/lib/reduced-motion.js +6 -0
- package/dist/lib/row-index.d.ts +10 -0
- package/dist/lib/row-index.js +8 -0
- package/dist/lib/row-order.d.ts +28 -0
- package/dist/lib/row-order.js +10 -0
- package/dist/lib/scroll-column.d.ts +27 -0
- package/dist/lib/scroll-column.js +18 -0
- package/dist/lib/shape-context.d.ts +30 -0
- package/dist/lib/shape-context.js +12 -0
- package/dist/lib/size-context.d.ts +94 -0
- package/dist/lib/size-context.js +20 -0
- package/dist/lib/size-scale.d.ts +79 -0
- package/dist/lib/size-scale.js +8 -0
- package/dist/lib/slot.d.ts +12 -0
- package/dist/lib/slot.js +6 -0
- package/dist/lib/springs.d.ts +44 -0
- package/dist/lib/springs.js +8 -0
- package/dist/lib/surface-classes.d.ts +19 -0
- package/dist/lib/surface-classes.js +10 -0
- package/dist/lib/surface-context.d.ts +9 -0
- package/dist/lib/surface-context.js +8 -0
- package/dist/lib/use-controllable-state.d.ts +2 -0
- package/dist/lib/use-controllable-state.js +6 -0
- package/dist/lib/use-fluid-hover.d.ts +138 -0
- package/dist/lib/use-fluid-hover.js +14 -0
- package/dist/lib/use-is-mobile.d.ts +2 -0
- package/dist/lib/use-is-mobile.js +6 -0
- package/dist/lib/use-keyboard-nav-gate.d.ts +20 -0
- package/dist/lib/use-keyboard-nav-gate.js +7 -0
- package/dist/lib/use-merge-split.d.ts +47 -0
- package/dist/lib/use-merge-split.js +15 -0
- package/dist/lib/use-morph.d.ts +101 -0
- package/dist/lib/use-morph.js +20 -0
- package/dist/lib/use-touch-primary.d.ts +2 -0
- package/dist/lib/use-touch-primary.js +6 -0
- package/dist/lib/utils.d.ts +3 -0
- package/dist/lib/utils.js +6 -0
- package/dist/preset.d.ts +17 -0
- package/dist/preset.js +22 -2
- package/dist/primitives/code/index.js +7 -5
- package/dist/primitives/craft/index.js +9 -6
- package/dist/primitives/fluid-hover/index.js +9 -8
- package/dist/primitives/morph/index.js +15 -10
- package/dist/primitives/motion/index.js +10 -8
- package/dist/primitives/scroll-area/index.js +4 -3
- package/dist/primitives/separator/index.d.ts +25 -0
- package/dist/primitives/separator/index.js +9 -0
- package/dist/primitives/sizes/index.js +6 -4
- package/dist/primitives/skeleton/index.d.ts +35 -0
- package/dist/primitives/skeleton/index.js +12 -0
- package/dist/primitives/surface/index.js +6 -4
- package/dist/shared/{components-3mkepka5.js → components-0jmqhx2k.js} +28 -25
- package/dist/shared/components-124epb7f.js +53 -0
- package/dist/shared/{components-gz8zf3ec.js → components-1k0wv53d.js} +15 -43
- package/dist/shared/components-26snnbs9.js +322 -0
- package/dist/shared/{components-2dxkv6zm.js → components-2c0qcfx4.js} +8 -7
- package/dist/shared/{components-473g814w.js → components-2mt9te6d.js} +243 -137
- package/dist/shared/{components-hawvy2qz.js → components-2z48g4fp.js} +9 -30
- package/dist/shared/components-34txk9rj.js +35 -0
- package/dist/shared/components-3h92d94m.js +23 -0
- package/dist/shared/{components-zfnmn5d4.js → components-3sp623ae.js} +36 -31
- package/dist/shared/{components-jwst3vtj.js → components-3wxkjgqn.js} +186 -76
- package/dist/shared/{components-s2bz1xpk.js → components-49bsynbw.js} +8 -5
- package/dist/shared/components-4bwsd46w.js +71 -0
- package/dist/shared/components-4n4f5m3e.js +297 -0
- package/dist/shared/{components-0q0sqfh4.js → components-4y816hwp.js} +27 -49
- package/dist/shared/components-52ngsgca.js +399 -0
- package/dist/shared/{components-y9y0g8er.js → components-52r5763g.js} +22 -19
- package/dist/shared/components-559tf7yb.js +28 -0
- package/dist/shared/components-5mkga9hj.js +440 -0
- package/dist/shared/{components-kwa6mrf9.js → components-5s0nfqvw.js} +14 -9
- package/dist/shared/{components-dk0gjr5a.js → components-60atskhp.js} +19 -18
- package/dist/shared/{components-rr7a6ja9.js → components-658b26nb.js} +21 -18
- package/dist/shared/{components-xxvdw7k1.js → components-65vwfjs6.js} +37 -34
- package/dist/shared/{components-frw34b65.js → components-6j2fh74k.js} +114 -123
- package/dist/shared/components-6zg1yft7.js +167 -0
- package/dist/shared/components-6zng858q.js +53 -0
- package/dist/shared/components-72w4ge68.js +64 -0
- package/dist/shared/{components-cv16mryv.js → components-78x40gee.js} +13 -7
- package/dist/shared/{components-b2wyq2cq.js → components-7d4r7hh1.js} +39 -57
- package/dist/shared/components-7ntvvegr.js +316 -0
- package/dist/shared/components-8nwsqjn8.js +34 -0
- package/dist/shared/{components-5xc2j8jz.js → components-8tt3xbb5.js} +19 -18
- package/dist/shared/{components-vc7vq5nz.js → components-9as3rf68.js} +6 -3
- package/dist/shared/components-9mys9jna.js +255 -0
- package/dist/shared/components-adkarj8n.js +35 -0
- package/dist/shared/{components-b3c5et5b.js → components-ae9fncz4.js} +32 -31
- package/dist/shared/{components-hqeq3w9q.js → components-awra5sff.js} +24 -16
- package/dist/shared/components-b1e239g3.js +28 -0
- package/dist/shared/{components-q5xj5c16.js → components-b1f2bjf1.js} +9 -8
- package/dist/shared/{components-qmmjx1qw.js → components-bn42vq6d.js} +71 -72
- package/dist/shared/components-c96g82th.js +1444 -0
- package/dist/shared/components-c9yr4ed1.js +44 -0
- package/dist/shared/components-d3shbwqq.js +95 -0
- package/dist/shared/{components-jjw69c4c.js → components-dcc02hs2.js} +7 -4
- package/dist/shared/{components-men63rwj.js → components-de99ykt7.js} +3 -2
- package/dist/shared/components-dhgk6mbt.js +16 -0
- package/dist/shared/components-drxrtnp2.js +25 -0
- package/dist/shared/{components-6p8tfcd6.js → components-ds9fqat9.js} +21 -18
- package/dist/shared/{components-mjthm1kf.js → components-egy4tyjt.js} +11 -8
- package/dist/shared/components-f0xfpkhh.js +82 -0
- package/dist/shared/components-f7h57m6t.js +59 -0
- package/dist/shared/{components-d6xtjht3.js → components-fjkpbg6n.js} +33 -30
- package/dist/shared/{components-gc5vtq2f.js → components-gh0qeey3.js} +63 -29
- package/dist/shared/{components-39jq9va1.js → components-gvbapfen.js} +29 -24
- package/dist/shared/{components-5vgpnxgy.js → components-gzesg7hy.js} +19 -16
- package/dist/shared/components-h6p86be3.js +19 -0
- package/dist/shared/components-h83az0bz.js +33 -0
- package/dist/shared/components-jjrj6ymn.js +104 -0
- package/dist/shared/{components-wvp1m52g.js → components-jp5v0m0f.js} +15 -14
- package/dist/shared/{components-zrkzcayz.js → components-jqpefjsn.js} +54 -6
- package/dist/shared/{components-1z6tdadk.js → components-k74j4hwh.js} +20 -17
- package/dist/shared/components-kswxnm3h.js +24 -0
- package/dist/shared/{components-jbbmdr74.js → components-mkkbbgb7.js} +36 -17
- package/dist/shared/{components-pbmqctfa.js → components-mpw667rb.js} +169 -65
- package/dist/shared/{components-s5ngg32c.js → components-paa08427.js} +121 -47
- package/dist/shared/{components-vfbhxf5r.js → components-pd42emw3.js} +6 -5
- package/dist/shared/{components-hw9yk32z.js → components-pmfszv7a.js} +35 -23
- package/dist/shared/{components-chacnk61.js → components-pw06rbxq.js} +19 -19
- package/dist/shared/components-q03cktx8.js +246 -0
- package/dist/shared/{components-kvr7f9hy.js → components-qdm3yh26.js} +19 -10
- package/dist/shared/{components-27nnvrwe.js → components-qs4rkqsj.js} +15 -14
- package/dist/shared/{components-s083f38z.js → components-r6gbmkew.js} +22 -21
- package/dist/shared/{components-ta7shw28.js → components-r8eqs7gz.js} +4 -4
- package/dist/shared/{components-sn1rxx1d.js → components-rm02h9nt.js} +12 -11
- package/dist/shared/{components-c6s6b43g.js → components-rrbpmfx4.js} +3 -2
- package/dist/shared/{components-n7mjr357.js → components-tz1fv35x.js} +3 -2
- package/dist/shared/components-tz4gaf1v.js +23 -0
- package/dist/shared/{components-zx6b6yby.js → components-vf45j12c.js} +4 -18
- package/dist/shared/{components-3ky44908.js → components-vfjc9jzz.js} +2 -3
- package/dist/shared/{components-39vmfq9s.js → components-vsa625vk.js} +21 -20
- package/dist/shared/components-wawnmkph.js +500 -0
- package/dist/shared/components-wktrrb4m.js +38 -0
- package/dist/shared/{components-8snj490h.js → components-wwd363me.js} +78 -32
- package/dist/shared/{components-5pnw8183.js → components-xcwvgcrd.js} +23 -22
- package/dist/shared/components-xdqmt6ed.js +20 -0
- package/dist/shared/{components-h2ajmhwn.js → components-y1w3a6wx.js} +107 -35
- package/dist/shared/{components-ar66gnt0.js → components-y6578b8h.js} +37 -34
- package/dist/shared/{components-59efxjd9.js → components-yhgq0qxx.js} +13 -12
- package/dist/shared/{components-jthe1pwb.js → components-yz84gpkq.js} +101 -404
- package/dist/shared/components-z3v0n0de.js +11 -0
- package/dist/shared/{components-7r3etddy.js → components-z9n19fyk.js} +19 -54
- package/dist/shared/components-zbvjzh1p.js +141 -0
- package/dist/styles.css +241 -16
- package/dist/tailwind.css +13 -0
- package/dist/tokens.css +34 -0
- package/index.ts +60 -0
- package/lib/date-locale.ts +50 -0
- package/lib/field-classes.ts +115 -0
- package/lib/field-variants.ts +117 -0
- package/lib/fluid-hover-highlight.tsx +139 -0
- package/lib/focus-ring.tsx +46 -0
- package/lib/goo-indicator.tsx +261 -0
- package/lib/icon-context.tsx +310 -0
- package/lib/inert.ts +22 -0
- package/lib/morph-layers.tsx +102 -0
- package/lib/morph-part.tsx +49 -0
- package/lib/popup.ts +52 -0
- package/lib/reduced-motion.ts +19 -0
- package/lib/row-index.tsx +29 -0
- package/lib/row-order.tsx +115 -0
- package/lib/scroll-column.tsx +244 -0
- package/lib/shape-context.tsx +124 -0
- package/lib/size-context.tsx +66 -0
- package/lib/size-scale.ts +115 -0
- package/lib/slot.ts +25 -0
- package/lib/springs.ts +62 -0
- package/lib/surface-classes.ts +58 -0
- package/lib/surface-context.tsx +21 -0
- package/lib/use-controllable-state.ts +44 -0
- package/lib/use-fluid-hover.ts +541 -0
- package/lib/use-is-mobile.ts +25 -0
- package/lib/use-keyboard-nav-gate.ts +45 -0
- package/lib/use-merge-split.tsx +209 -0
- package/lib/use-morph.ts +557 -0
- package/lib/use-touch-primary.tsx +43 -0
- package/lib/utils.ts +37 -0
- package/package.json +177 -4
- package/preset.ts +196 -0
- package/primitives/code/examples.mdx +27 -0
- package/primitives/code/index.tsx +80 -0
- package/primitives/craft/demos.tsx +173 -0
- package/primitives/craft/examples.mdx +138 -0
- package/primitives/craft/index.tsx +76 -0
- package/primitives/fluid-hover/examples.mdx +84 -0
- package/primitives/fluid-hover/index.tsx +316 -0
- package/primitives/morph/examples.mdx +135 -0
- package/primitives/morph/index.tsx +192 -0
- package/primitives/motion/examples.mdx +100 -0
- package/primitives/motion/index.tsx +160 -0
- package/primitives/scroll-area/examples.mdx +58 -0
- package/primitives/scroll-area/index.tsx +200 -0
- package/primitives/separator/examples.mdx +30 -0
- package/primitives/separator/index.tsx +53 -0
- package/primitives/sizes/examples.mdx +167 -0
- package/primitives/sizes/index.tsx +89 -0
- package/primitives/skeleton/examples.mdx +51 -0
- package/primitives/skeleton/index.tsx +72 -0
- package/primitives/skeleton/skeleton.css +15 -0
- package/primitives/surface/examples.mdx +173 -0
- package/primitives/surface/index.tsx +109 -0
- package/primitives/surface/surface.css +176 -0
- package/dist/shared/components-0sn7ypjw.js +0 -61
- package/dist/shared/components-1434p1mh.js +0 -123
- package/dist/shared/components-a86dytt0.js +0 -986
- package/dist/shared/components-gf5s6ap9.js +0 -42
- package/dist/shared/components-hyq23phv.js +0 -310
- package/dist/shared/components-ng72k97e.js +0 -128
- package/dist/shared/components-raknhppb.js +0 -64
- package/dist/shared/components-vynjtrk6.js +0 -19
- package/dist/shared/components-wy9jpqny.js +0 -146
- /package/dist/shared/{components-1v7rxytx.js → components-3ey0hzpb.js} +0 -0
- /package/dist/shared/{components-ezxhrmer.js → components-jcnjay5g.js} +0 -0
|
@@ -0,0 +1,1383 @@
|
|
|
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/input-message.tsx` @ b3587bdbd83fc66c2a6aae3817ffb856cb09260b
|
|
5
|
+
* MIT License © 2026 Micka Touillaud — fluidfunctionalism.com (notice: LICENSE.fluid-functionalism)
|
|
6
|
+
* Local modifications:
|
|
7
|
+
* - `"use client"` directive dropped (no RSC here).
|
|
8
|
+
* - `framer-motion` → `motion/react`; `@/lib/*` and `@/hooks/*` rewritten to
|
|
9
|
+
* `../../../lib/*` (`SizeProvider` → `../../../primitives/sizes`); `@/components/ui/fluid-hover-highlight` →
|
|
10
|
+
* `../../../lib/fluid-hover-highlight`; `@/registry/radix/{button,tooltip}` →
|
|
11
|
+
* `../../button` / `../../overlays/tooltip`; `@/registry/default/file-thumbnail` → `./file-thumbnail`
|
|
12
|
+
* (vendored beside this file).
|
|
13
|
+
* - Uncontrolled twins (Base UI shape, via `lib/use-controllable-state`), so the
|
|
14
|
+
* composer works with no state in the caller. The controlled API is unchanged — every
|
|
15
|
+
* `value` / `files` / `queue` / `status` prop still wins and still reports through its
|
|
16
|
+
* callback:
|
|
17
|
+
* · `value` / `onValueChange` are now optional and gain `defaultValue`;
|
|
18
|
+
* · `defaultFiles` seeds an internal file list — attachments are enabled by
|
|
19
|
+
* `onFilesChange` OR `defaultFiles` (upstream: `onFilesChange` alone);
|
|
20
|
+
* · `defaultQueue` seeds an internal queue — the queue is enabled by
|
|
21
|
+
* `onQueueChange` OR `defaultQueue`, still only alongside a status;
|
|
22
|
+
* · `defaultStatus` seeds an internal status (plus an optional `onStatusChange`);
|
|
23
|
+
* with the status uncontrolled the Stop control is offered even without
|
|
24
|
+
* `onStop` and flips the status to `"idle"` itself, which is the edge that
|
|
25
|
+
* auto-dispatches the head of the queue;
|
|
26
|
+
* · an uncontrolled draft / file list clears itself after `onSend`, which a
|
|
27
|
+
* controlled consumer does by hand.
|
|
28
|
+
* - React 18 / `noUncheckedIndexedAccess`: `useRef<HTMLDivElement | null>` where the ref is
|
|
29
|
+
* assigned, guards on `queue[0]`, `history[i]`, `suggestions[i]` and the swap in
|
|
30
|
+
* `moveQueued`, and `FilePreviewTile` turned into a `forwardRef` — it renders inside
|
|
31
|
+
* `<AnimatePresence mode="popLayout">`, which measures the exiting child through a ref
|
|
32
|
+
* (React 19 passes `ref` as a plain prop; React 18 warns and drops it).
|
|
33
|
+
* - modo item: TSDoc from the FF "InputMessage" docs page (its Playground section is
|
|
34
|
+
* skipped, and the transcript around the Attachments / Send Handler demos is dropped —
|
|
35
|
+
* `ChatMessage` is not part of this port).
|
|
36
|
+
* - Styling reads DS tokens (AGENTS.md styling): `text-[Npx]` →
|
|
37
|
+
* `text-<role>[-compact]`; `leading-[18px]` → `leading-4.5`; inline
|
|
38
|
+
* `fontVariationSettings` → `weight-*`; the hex focus-ring fallback →
|
|
39
|
+
* `ring-focus-ring` / `border-focus-ring`; literal colors → color tokens;
|
|
40
|
+
* `rounded-[Npx]` → radius tokens; `duration-80|120|160` and tier-length JS
|
|
41
|
+
* durations → `duration-<tier>` / `spring.*`.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { AnimatePresence, motion, Reorder, useReducedMotion } from 'motion/react'
|
|
45
|
+
import {
|
|
46
|
+
type ChangeEvent,
|
|
47
|
+
forwardRef,
|
|
48
|
+
type HTMLAttributes,
|
|
49
|
+
type DragEvent as ReactDragEvent,
|
|
50
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
51
|
+
type MouseEvent as ReactMouseEvent,
|
|
52
|
+
type ReactNode,
|
|
53
|
+
type TextareaHTMLAttributes,
|
|
54
|
+
useCallback,
|
|
55
|
+
useEffect,
|
|
56
|
+
useId,
|
|
57
|
+
useLayoutEffect,
|
|
58
|
+
useMemo,
|
|
59
|
+
useRef,
|
|
60
|
+
useState,
|
|
61
|
+
} from 'react'
|
|
62
|
+
import { FluidHoverHighlight } from '../../../lib/fluid-hover-highlight'
|
|
63
|
+
import { useIcon } from '../../../lib/icon-context'
|
|
64
|
+
import { useShape } from '../../../lib/shape-context'
|
|
65
|
+
import { type SizeVariant, useSize } from '../../../lib/size-context'
|
|
66
|
+
import { spring } from '../../../lib/springs'
|
|
67
|
+
import { surfaceClasses } from '../../../lib/surface-classes'
|
|
68
|
+
import { SurfaceProvider } from '../../../lib/surface-context'
|
|
69
|
+
import { useControllableState } from '../../../lib/use-controllable-state'
|
|
70
|
+
import { useFluidHover, useRegisterFluidHoverItem } from '../../../lib/use-fluid-hover'
|
|
71
|
+
import { cn } from '../../../lib/utils'
|
|
72
|
+
import { SizeProvider } from '../../../primitives/sizes'
|
|
73
|
+
import { Button } from '../../button'
|
|
74
|
+
import { Tooltip } from '../../overlays/tooltip'
|
|
75
|
+
import { FileThumbnail } from './file-thumbnail'
|
|
76
|
+
|
|
77
|
+
const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Measured layout height for one of the composer's collapsible regions
|
|
81
|
+
* (attachments, queue, suggestions).
|
|
82
|
+
*
|
|
83
|
+
* These animate to a self-measured PIXEL height rather than `height: "auto"`:
|
|
84
|
+
* motion resolves an "auto" target from the element's *visual* (transformed)
|
|
85
|
+
* size, so under a scaled ancestor the region springs out to scale× its real
|
|
86
|
+
* height and snaps back when "auto" lands, which reads as the region
|
|
87
|
+
* ballooning and then correcting. Same treatment as Accordion's content
|
|
88
|
+
* height.
|
|
89
|
+
*
|
|
90
|
+
* Returns a ref for the region's CONTENT element. The height it reports is the
|
|
91
|
+
* clipping parent's scrollHeight, so inner margins count (the parent's
|
|
92
|
+
* overflow-hidden makes it a block formatting context, so they don't collapse
|
|
93
|
+
* out) and the value stays correct while the animated height is mid-flight.
|
|
94
|
+
* Observing the child rather than the parent keeps the ResizeObserver out of a
|
|
95
|
+
* feedback loop with that animation.
|
|
96
|
+
*/
|
|
97
|
+
function useRegionHeight() {
|
|
98
|
+
const roRef = useRef<ResizeObserver | null>(null)
|
|
99
|
+
const [height, setHeight] = useState<number | null>(null)
|
|
100
|
+
const ref = useCallback((el: HTMLElement | null) => {
|
|
101
|
+
roRef.current?.disconnect()
|
|
102
|
+
roRef.current = null
|
|
103
|
+
if (!el) return
|
|
104
|
+
const sync = () => {
|
|
105
|
+
const next = el.parentElement?.scrollHeight ?? el.offsetHeight
|
|
106
|
+
if (next > 0) setHeight(next)
|
|
107
|
+
}
|
|
108
|
+
sync()
|
|
109
|
+
const ro = new ResizeObserver(sync)
|
|
110
|
+
ro.observe(el)
|
|
111
|
+
roRef.current = ro
|
|
112
|
+
}, [])
|
|
113
|
+
return [ref, height] as const
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Touch devices have no hover, so hover-revealed affordances (like a queued
|
|
117
|
+
// row's × button) would never appear. `(hover: none)` flags those so they can
|
|
118
|
+
// be shown persistently instead. SSR-safe: starts false, resolves on mount.
|
|
119
|
+
function useIsTouch() {
|
|
120
|
+
const [isTouch, setIsTouch] = useState(false)
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
const mq = window.matchMedia('(hover: none)')
|
|
123
|
+
const update = () => setIsTouch(mq.matches)
|
|
124
|
+
update()
|
|
125
|
+
mq.addEventListener('change', update)
|
|
126
|
+
return () => mq.removeEventListener('change', update)
|
|
127
|
+
}, [])
|
|
128
|
+
return isTouch
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const DEFAULT_ACCEPT = 'image/png,image/jpeg,application/pdf'
|
|
132
|
+
|
|
133
|
+
// Stable empty seeds: `useControllableState` reads its default once, but a
|
|
134
|
+
// fresh literal on every render would still churn the identity it stores.
|
|
135
|
+
const NO_FILES: File[] = []
|
|
136
|
+
const NO_QUEUE: QueuedMessage[] = []
|
|
137
|
+
|
|
138
|
+
type InputMessageStatus = 'idle' | 'streaming'
|
|
139
|
+
|
|
140
|
+
interface InputMessageSlotContext {
|
|
141
|
+
/** Opens the native file picker via the hidden `<input type="file">`.
|
|
142
|
+
* Pass `acceptOverride` (e.g. `"image/*"`) to scope the picker to a
|
|
143
|
+
* subset of the component's accept types just for this invocation. */
|
|
144
|
+
openFilePicker: (acceptOverride?: string) => void
|
|
145
|
+
/** Currently-attached files. */
|
|
146
|
+
files: File[]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
type InputMessageSlot = ReactNode | ((ctx: InputMessageSlotContext) => ReactNode)
|
|
150
|
+
|
|
151
|
+
/** A message held in the queue while the assistant is responding. Carries the
|
|
152
|
+
* trimmed text plus a snapshot of the files attached when it was queued, so
|
|
153
|
+
* double-click-to-edit can restore both. `id` is a stable key minted on enqueue. */
|
|
154
|
+
interface QueuedMessage {
|
|
155
|
+
id: string
|
|
156
|
+
text: string
|
|
157
|
+
files: File[]
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
interface InputMessageProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'defaultValue'> {
|
|
161
|
+
/** Step on the size ladder (see Sizes). Wins over the surrounding SizeProvider and propagates to the composer's rows, buttons and queued messages. */
|
|
162
|
+
size?: SizeVariant
|
|
163
|
+
/** Controlled textarea value. Pair with `onValueChange`; omit both for an uncontrolled composer. */
|
|
164
|
+
value?: string
|
|
165
|
+
/** Initial textarea value for an uncontrolled composer. Defaults to `""`. */
|
|
166
|
+
defaultValue?: string
|
|
167
|
+
/** Called with the new value on every textarea change. */
|
|
168
|
+
onValueChange?: (value: string) => void
|
|
169
|
+
/** Fires on Enter (without Shift) or send-button click, and when a queued message auto-dispatches. Receives the trimmed value, the attached files, and — for auto-dispatched queue items — `meta.queuedId`. Skipped when the value is empty and no files are attached. */
|
|
170
|
+
onSend?: (value: string, files: File[], meta?: { queuedId?: string }) => void
|
|
171
|
+
/** Placeholder shown when the value is empty. While a file is dragged over the component (and attachments are enabled) it swaps to "Drop files here to add to chat". Defaults to `"Ask me anything…"`. */
|
|
172
|
+
placeholder?: string
|
|
173
|
+
/** Content rendered in the bottom-left action area. May be a render-fn that receives `{ openFilePicker, files }` — `openFilePicker(acceptOverride?)` opens the native file picker. */
|
|
174
|
+
leftSlot?: InputMessageSlot
|
|
175
|
+
/** Content rendered in the bottom-right action area, before the built-in send button. Same render-fn shape as `leftSlot`. */
|
|
176
|
+
rightSlot?: InputMessageSlot
|
|
177
|
+
/** Disables the textarea, send button, and drag-and-drop. Defaults to `false`. */
|
|
178
|
+
disabled?: boolean
|
|
179
|
+
/** Minimum visible rows before the textarea grows. Defaults to 1. */
|
|
180
|
+
minRows?: number
|
|
181
|
+
/** Maximum visible rows before the textarea starts to scroll. Defaults to 8. */
|
|
182
|
+
maxRows?: number
|
|
183
|
+
/** When true, clicking anywhere on the surrounding container (outside of buttons / links / inputs) focuses the textarea. Defaults to `true`. */
|
|
184
|
+
clickToFocus?: boolean
|
|
185
|
+
/** Accessible label for the send button. Defaults to `"Send"`. */
|
|
186
|
+
sendLabel?: string
|
|
187
|
+
/** Controlled list of attached files. Pair with `onFilesChange` to enable drag-and-drop and the file-picker slot helper. */
|
|
188
|
+
files?: File[]
|
|
189
|
+
/** Initial attachments for an uncontrolled composer. Providing it (even as `[]`) enables attachment behavior on its own. */
|
|
190
|
+
defaultFiles?: File[]
|
|
191
|
+
/** Called when files are added (drag-drop or picker) or removed via the preview tile's × button. Duplicate drops of the same file (same name + size + lastModified) are silently de-duplicated. */
|
|
192
|
+
onFilesChange?: (files: File[]) => void
|
|
193
|
+
/** Accepted MIME types as a comma-separated string. Used by both the file picker and the drag-and-drop filter. Defaults to `"image/png,image/jpeg,application/pdf"`. */
|
|
194
|
+
accept?: string
|
|
195
|
+
/** Maximum number of attached files. Extra files beyond this limit are dropped. */
|
|
196
|
+
maxFiles?: number
|
|
197
|
+
/** Side length (in pixels) of each preview tile. Images use object-cover; PDFs render the first page via pdfjs; other types fall back to a centered icon. Defaults to 80. */
|
|
198
|
+
filePreviewSize?: number
|
|
199
|
+
/** Extra props forwarded to the underlying textarea (value, onChange, onKeyDown, disabled and placeholder are controlled by the component). */
|
|
200
|
+
textareaProps?: Omit<
|
|
201
|
+
TextareaHTMLAttributes<HTMLTextAreaElement>,
|
|
202
|
+
'value' | 'onChange' | 'onKeyDown' | 'disabled' | 'placeholder'
|
|
203
|
+
>
|
|
204
|
+
/** Assistant response state. When `"streaming"`, the send button becomes a Stop control (empty draft) or a Queue action (non-empty draft). On the streaming→idle edge the next queued message auto-dispatches via `onSend`. Leave it and `defaultStatus` undefined for the send-immediately behavior. */
|
|
205
|
+
status?: InputMessageStatus
|
|
206
|
+
/** Initial status for an uncontrolled composer — the component then owns the streaming flag, and Stop flips it to `"idle"` itself. */
|
|
207
|
+
defaultStatus?: InputMessageStatus
|
|
208
|
+
/** Called whenever the status changes while it is uncontrolled (currently: the Stop control). */
|
|
209
|
+
onStatusChange?: (status: InputMessageStatus) => void
|
|
210
|
+
/** Fires when the Stop control is pressed (streaming + empty draft). Halt the current response and set status to `"idle"` — that edge immediately dispatches the next queued message. */
|
|
211
|
+
onStop?: () => void
|
|
212
|
+
/** Controlled queue of pending messages, rendered as reorderable rows above the textarea. Double-click (or Enter/F2) edits a row back into the composer; the × (or Delete) removes it; drag — or Alt+↑/↓ — reorders. Requires a status. */
|
|
213
|
+
queue?: QueuedMessage[]
|
|
214
|
+
/** Initial queue for an uncontrolled composer. Providing it (even as `[]`) enables the queue on its own, alongside `status` or `defaultStatus`. */
|
|
215
|
+
defaultQueue?: QueuedMessage[]
|
|
216
|
+
/** Called whenever the queue changes — enqueue, edit, delete, reorder, or auto-dispatch. Each QueuedMessage is `{ id, text, files }`. */
|
|
217
|
+
onQueueChange?: (queue: QueuedMessage[]) => void
|
|
218
|
+
/** Render the built-in reorderable queue rows above the textarea. Set to false to suppress them and render the queue yourself — enqueue and auto-dispatch still run. Defaults to `true`. */
|
|
219
|
+
showQueue?: boolean
|
|
220
|
+
/** Previously-sent messages, oldest first. With the textarea focused, ArrowUp (caret on the first line) recalls the previous message and walks backward; ArrowDown (caret on the last line) walks forward toward the in-progress draft. Editing or sending exits history mode. Defaults to `[]`. */
|
|
221
|
+
history?: string[]
|
|
222
|
+
/** Suggested prompt rendered as the placeholder (with a Tab keycap) while the draft is empty. Pressing Tab fills it into the composer — it doesn't send. Takes precedence over `placeholder`. */
|
|
223
|
+
placeholderSuggestion?: string
|
|
224
|
+
/** Suggested prompts listed under the action bar while the draft is empty. ArrowDown moves a highlight into the list (focus stays in the textarea), ArrowUp walks back up and out, Enter or click fills the highlighted prompt. Escape drops the highlight; typing collapses the list. */
|
|
225
|
+
suggestions?: string[]
|
|
226
|
+
/** Extra classes for the composer surface. */
|
|
227
|
+
className?: string
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ─── File preview tile ────────────────────────────────────────────────────
|
|
231
|
+
// Composer-row tile: a FileThumbnail wrapped with enter/exit motion and a
|
|
232
|
+
// hover-revealed remove (×) button.
|
|
233
|
+
interface FilePreviewTileProps {
|
|
234
|
+
file: File
|
|
235
|
+
onRemove: () => void
|
|
236
|
+
size: number
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// React 18: the tile is rendered inside `<AnimatePresence mode="popLayout">`,
|
|
240
|
+
// which measures the exiting child through a ref. Upstream can be a plain
|
|
241
|
+
// function component (React 19 passes `ref` as a prop); here it has to be a
|
|
242
|
+
// forwardRef, or React logs "Function components cannot be given refs" and the
|
|
243
|
+
// pop-out measurement never lands.
|
|
244
|
+
const FilePreviewTile = forwardRef<HTMLDivElement, FilePreviewTileProps>(function FilePreviewTile(
|
|
245
|
+
{ file, onRemove, size },
|
|
246
|
+
ref,
|
|
247
|
+
) {
|
|
248
|
+
const XIcon = useIcon('x')
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<motion.div
|
|
252
|
+
ref={ref}
|
|
253
|
+
// `layout` animates sibling tiles into the gap when one is removed.
|
|
254
|
+
// Enter: spring.fast (0.08s) — the small-state-flip tier.
|
|
255
|
+
// Exit: 0.06s linear — exits should be slightly faster than enter.
|
|
256
|
+
layout
|
|
257
|
+
initial={{ opacity: 0, scale: 0.9 }}
|
|
258
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
259
|
+
exit={{ opacity: 0, scale: 0.9, transition: spring.fast.exit }}
|
|
260
|
+
transition={spring.fast}
|
|
261
|
+
// `cursor-default` opts out of the parent's `cursor-text` so hovering
|
|
262
|
+
// a preview tile doesn't look like it'll land in the textarea.
|
|
263
|
+
className="relative shrink-0 cursor-default group/tile"
|
|
264
|
+
>
|
|
265
|
+
<FileThumbnail file={file} size={size} />
|
|
266
|
+
<Tooltip content="Remove" side="top">
|
|
267
|
+
<button
|
|
268
|
+
type="button"
|
|
269
|
+
onClick={e => {
|
|
270
|
+
e.stopPropagation()
|
|
271
|
+
onRemove()
|
|
272
|
+
}}
|
|
273
|
+
aria-label={`Remove ${file.name}`}
|
|
274
|
+
// Pinned to the light scheme (`light` class: dark circle + light X)
|
|
275
|
+
// regardless of theme — the close badge needs to read as a "delete
|
|
276
|
+
// affordance" over arbitrary image/PDF content, so it sits at a fixed
|
|
277
|
+
// contrast instead of flipping with the surrounding surface.
|
|
278
|
+
className="light absolute top-1 right-1 w-5 h-5 rounded-full bg-foreground text-background opacity-0 group-hover/tile:opacity-100 transition-opacity duration-fast flex items-center justify-center cursor-pointer outline-none focus-visible:opacity-100 focus-visible:ring-1 focus-visible:ring-focus-ring"
|
|
279
|
+
>
|
|
280
|
+
<XIcon size={12} strokeWidth={2.5} />
|
|
281
|
+
</button>
|
|
282
|
+
</Tooltip>
|
|
283
|
+
</motion.div>
|
|
284
|
+
)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
// ─── Queued message row ───────────────────────────────────────────────────
|
|
288
|
+
// A pending message in the queue: a recessed, draggable row that reads as
|
|
289
|
+
// "staged, not live". Double-click (or Enter/F2) edits it back into the
|
|
290
|
+
// composer; the hover-revealed × (or Delete) removes it; drag — or Alt+↑/↓ —
|
|
291
|
+
// reorders. Top of the list is next to dispatch.
|
|
292
|
+
interface QueuedRowProps {
|
|
293
|
+
item: QueuedMessage
|
|
294
|
+
index: number
|
|
295
|
+
total: number
|
|
296
|
+
reduceMotion: boolean
|
|
297
|
+
isTouch: boolean
|
|
298
|
+
onEdit: (item: QueuedMessage) => void
|
|
299
|
+
onRemove: (item: QueuedMessage) => void
|
|
300
|
+
onMove: (item: QueuedMessage, dir: -1 | 1) => void
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function QueuedRow({ item, index, total, reduceMotion, isTouch, onEdit, onRemove, onMove }: QueuedRowProps) {
|
|
304
|
+
const XIcon = useIcon('x')
|
|
305
|
+
const ImageIcon = useIcon('image')
|
|
306
|
+
const compactStep = useSize().variant === 'compact'
|
|
307
|
+
const fileCount = item.files.length
|
|
308
|
+
const label = item.text || `${fileCount} attachment${fileCount === 1 ? '' : 's'}`
|
|
309
|
+
|
|
310
|
+
return (
|
|
311
|
+
<Reorder.Item
|
|
312
|
+
value={item}
|
|
313
|
+
layout
|
|
314
|
+
// Enter: spring-fast chip category. Exit slightly faster (0.06s linear).
|
|
315
|
+
// Reduced-motion drops the scale.
|
|
316
|
+
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.97 }}
|
|
317
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
318
|
+
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.97, transition: spring.fast.exit }}
|
|
319
|
+
transition={spring.fast}
|
|
320
|
+
aria-label={`Queued message ${index + 1} of ${total}: ${label}`}
|
|
321
|
+
tabIndex={0}
|
|
322
|
+
onDoubleClick={() => onEdit(item)}
|
|
323
|
+
onKeyDown={e => {
|
|
324
|
+
if (e.key === 'Enter' || e.key === 'F2') {
|
|
325
|
+
e.preventDefault()
|
|
326
|
+
onEdit(item)
|
|
327
|
+
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
328
|
+
e.preventDefault()
|
|
329
|
+
onRemove(item)
|
|
330
|
+
} else if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
|
331
|
+
e.preventDefault()
|
|
332
|
+
onMove(item, e.key === 'ArrowUp' ? -1 : 1)
|
|
333
|
+
}
|
|
334
|
+
}}
|
|
335
|
+
className={cn(
|
|
336
|
+
// Fixed height (was py-1.5 around a 19.5px line box ≈ 31.5px) so the
|
|
337
|
+
// text-box trim on the label doesn't shrink the row.
|
|
338
|
+
'group/qrow flex items-center gap-2 rounded-lg bg-muted',
|
|
339
|
+
compactStep ? 'h-7 px-2 text-body-compact' : 'h-8 px-2.5 text-body',
|
|
340
|
+
'text-foreground/85 select-none outline-none',
|
|
341
|
+
'cursor-grab active:cursor-grabbing',
|
|
342
|
+
'focus-visible:ring-1 focus-visible:ring-focus-ring',
|
|
343
|
+
'weight-normal',
|
|
344
|
+
)}
|
|
345
|
+
>
|
|
346
|
+
{fileCount > 0 && (
|
|
347
|
+
<span className="flex shrink-0 items-center gap-0.5 text-muted-foreground">
|
|
348
|
+
<ImageIcon size={13} />
|
|
349
|
+
{item.text && <span className="tabular-nums">{fileCount}</span>}
|
|
350
|
+
</span>
|
|
351
|
+
)}
|
|
352
|
+
{/* py-1/-my-1 keeps truncate's overflow-hidden from clipping
|
|
353
|
+
ascenders and descenders outside the trimmed box. */}
|
|
354
|
+
<span className="min-w-0 flex-1 truncate [text-box:trim-both_cap_alphabetic] py-1 -my-1">{label}</span>
|
|
355
|
+
<Tooltip content="Remove" side="top">
|
|
356
|
+
<button
|
|
357
|
+
type="button"
|
|
358
|
+
// Stop the pointer-down from starting a Reorder drag, and the click
|
|
359
|
+
// from bubbling to the row's double-click/edit handler.
|
|
360
|
+
onPointerDown={e => e.stopPropagation()}
|
|
361
|
+
onClick={e => {
|
|
362
|
+
e.stopPropagation()
|
|
363
|
+
onRemove(item)
|
|
364
|
+
}}
|
|
365
|
+
aria-label={`Remove queued message: ${label}`}
|
|
366
|
+
className={cn(
|
|
367
|
+
'shrink-0 flex h-5 w-5 items-center justify-center rounded-full',
|
|
368
|
+
'text-muted-foreground hover:text-foreground hover:bg-hover',
|
|
369
|
+
// Hover devices reveal × on row-hover; touch has no hover, so keep
|
|
370
|
+
// it persistently visible there.
|
|
371
|
+
isTouch ? 'opacity-100' : 'opacity-0 group-hover/qrow:opacity-100 focus-visible:opacity-100',
|
|
372
|
+
'transition-opacity duration-fast cursor-pointer outline-none',
|
|
373
|
+
'focus-visible:ring-1 focus-visible:ring-focus-ring',
|
|
374
|
+
)}
|
|
375
|
+
>
|
|
376
|
+
<XIcon size={13} strokeWidth={2.5} />
|
|
377
|
+
</button>
|
|
378
|
+
</Tooltip>
|
|
379
|
+
</Reorder.Item>
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ─── Suggestion row ───────────────────────────────────────────────────────
|
|
384
|
+
// A suggested prompt in the listbox under the action bar. Registers itself
|
|
385
|
+
// with the fluid-hover system in an effect (MenuItem's pattern — an
|
|
386
|
+
// inline ref callback would re-register every render and keep the hook's
|
|
387
|
+
// measurement pass from ever settling). The highlight itself is the parent's
|
|
388
|
+
// sliding overlay, so the row only recolors its text when active.
|
|
389
|
+
interface SuggestionRowProps {
|
|
390
|
+
text: string
|
|
391
|
+
index: number
|
|
392
|
+
active: boolean
|
|
393
|
+
/** Show a ↓ keycap hint in the icon slot — the first row displays it while
|
|
394
|
+
* no row is highlighted, signposting that ArrowDown enters the list. */
|
|
395
|
+
keyHint: boolean
|
|
396
|
+
optionId: string
|
|
397
|
+
registerItem: (index: number, element: HTMLElement | null) => void
|
|
398
|
+
onSelect: () => void
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function SuggestionRow({ text, index, active, keyHint, optionId, registerItem, onSelect }: SuggestionRowProps) {
|
|
402
|
+
const EnterIcon = useIcon('corner-down-left')
|
|
403
|
+
const ArrowDownIcon = useIcon('arrow-down')
|
|
404
|
+
const compactStep = useSize().variant === 'compact'
|
|
405
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
406
|
+
|
|
407
|
+
useRegisterFluidHoverItem(registerItem, index, ref)
|
|
408
|
+
|
|
409
|
+
return (
|
|
410
|
+
<div
|
|
411
|
+
ref={ref}
|
|
412
|
+
id={optionId}
|
|
413
|
+
role="option"
|
|
414
|
+
aria-selected={active}
|
|
415
|
+
onClick={onSelect}
|
|
416
|
+
className={cn(
|
|
417
|
+
'relative flex cursor-pointer items-center gap-2',
|
|
418
|
+
// Text size mirrors the composer's textarea/placeholder (the rows
|
|
419
|
+
// read as prompt candidates, not metadata); heights follow the
|
|
420
|
+
// QueuedRow step ladder.
|
|
421
|
+
compactStep ? 'h-7 px-2 text-subtitle-compact' : 'h-8 px-2.5 text-subtitle',
|
|
422
|
+
'text-muted-foreground transition-colors duration-fast',
|
|
423
|
+
active && 'text-foreground',
|
|
424
|
+
'weight-normal',
|
|
425
|
+
)}
|
|
426
|
+
>
|
|
427
|
+
<span className="min-w-0 flex-1 truncate [text-box:trim-both_cap_alphabetic] py-1 -my-1">{text}</span>
|
|
428
|
+
{/* One icon slot: ↵ on the highlighted row; on the first row a muted ↓
|
|
429
|
+
takes the same slot while nothing is highlighted, signposting the
|
|
430
|
+
keyboard path into the list. */}
|
|
431
|
+
{!active && keyHint ? (
|
|
432
|
+
<ArrowDownIcon size={13} className="shrink-0 text-muted-foreground/70 transition-opacity duration-fast" />
|
|
433
|
+
) : (
|
|
434
|
+
<EnterIcon
|
|
435
|
+
size={13}
|
|
436
|
+
className={cn('shrink-0 transition-opacity duration-fast', active ? 'opacity-100' : 'opacity-0')}
|
|
437
|
+
/>
|
|
438
|
+
)}
|
|
439
|
+
</div>
|
|
440
|
+
)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ─── InputMessage ─────────────────────────────────────────────────────────
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Chat-style message composer with an auto-resizing textarea, flexible
|
|
447
|
+
* left/right action slots, and a built-in send button on a Surface-2
|
|
448
|
+
* substrate.
|
|
449
|
+
*
|
|
450
|
+
* The textarea grows from `minRows` to `maxRows` and then scrolls, Enter
|
|
451
|
+
* sends while Shift+Enter breaks the line, and the whole panel is one
|
|
452
|
+
* hairline ring that recolours in place for hover, focus and drag instead of
|
|
453
|
+
* layering a second border. Attachments drop anywhere on the composer (or
|
|
454
|
+
* come from `openFilePicker` in a slot) and preview as tiles — images
|
|
455
|
+
* object-cover, PDFs render their first page. `suggestions` open a listbox
|
|
456
|
+
* under the action bar that ↓/↑ walk without moving focus, and
|
|
457
|
+
* `placeholderSuggestion` writes a ghost prompt with a Tab keycap. With a
|
|
458
|
+
* `status` the send button morphs into Stop (empty draft) or Queue (draft
|
|
459
|
+
* typed while streaming); queued rows reorder by drag or Alt+↑/↓, edit back
|
|
460
|
+
* into the composer on double-click, and the head auto-dispatches on the
|
|
461
|
+
* streaming→idle edge.
|
|
462
|
+
*
|
|
463
|
+
* Every controlled prop has an uncontrolled twin — `defaultValue`,
|
|
464
|
+
* `defaultFiles`, `defaultQueue`, `defaultStatus` — so a composer can hold
|
|
465
|
+
* its own draft, attachments and queue with no state in the caller.
|
|
466
|
+
*
|
|
467
|
+
* @example {@include ./examples.mdx}
|
|
468
|
+
*/
|
|
469
|
+
const InputMessage = forwardRef<HTMLDivElement, InputMessageProps>(
|
|
470
|
+
(
|
|
471
|
+
{
|
|
472
|
+
size,
|
|
473
|
+
value: valueProp,
|
|
474
|
+
defaultValue,
|
|
475
|
+
onValueChange,
|
|
476
|
+
onSend,
|
|
477
|
+
placeholder = 'Ask me anything…',
|
|
478
|
+
leftSlot,
|
|
479
|
+
rightSlot,
|
|
480
|
+
disabled,
|
|
481
|
+
minRows = 1,
|
|
482
|
+
maxRows = 8,
|
|
483
|
+
clickToFocus = true,
|
|
484
|
+
sendLabel = 'Send',
|
|
485
|
+
files: filesProp,
|
|
486
|
+
defaultFiles,
|
|
487
|
+
onFilesChange,
|
|
488
|
+
accept = DEFAULT_ACCEPT,
|
|
489
|
+
maxFiles,
|
|
490
|
+
filePreviewSize = 80,
|
|
491
|
+
textareaProps,
|
|
492
|
+
status: statusProp,
|
|
493
|
+
defaultStatus,
|
|
494
|
+
onStatusChange,
|
|
495
|
+
onStop,
|
|
496
|
+
queue: queueProp,
|
|
497
|
+
defaultQueue,
|
|
498
|
+
onQueueChange,
|
|
499
|
+
showQueue = true,
|
|
500
|
+
history = [],
|
|
501
|
+
placeholderSuggestion,
|
|
502
|
+
suggestions,
|
|
503
|
+
className,
|
|
504
|
+
style,
|
|
505
|
+
...props
|
|
506
|
+
},
|
|
507
|
+
ref,
|
|
508
|
+
) => {
|
|
509
|
+
const shape = useShape()
|
|
510
|
+
const compactStep = useSize(size).variant === 'compact'
|
|
511
|
+
const ArrowUpIcon = useIcon('arrow-up')
|
|
512
|
+
const reduceMotion = useReducedMotion() ?? false
|
|
513
|
+
const isTouch = useIsTouch()
|
|
514
|
+
|
|
515
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
516
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
517
|
+
const [focusVisible, setFocusVisible] = useState(false)
|
|
518
|
+
const [dragOver, setDragOver] = useState(false)
|
|
519
|
+
const [hovered, setHovered] = useState(false)
|
|
520
|
+
|
|
521
|
+
// Split out onFocus/onBlur so the rest-spread onto the textarea can't
|
|
522
|
+
// clobber the composed handlers below.
|
|
523
|
+
const {
|
|
524
|
+
onFocus: _textareaOnFocus,
|
|
525
|
+
onBlur: _textareaOnBlur,
|
|
526
|
+
'aria-describedby': textareaDescribedBy,
|
|
527
|
+
...restTextareaProps
|
|
528
|
+
} = textareaProps ?? {}
|
|
529
|
+
|
|
530
|
+
// ── Controlled / uncontrolled state ───────────────────────────────
|
|
531
|
+
// Each pair keeps upstream's controlled contract (the prop wins, the
|
|
532
|
+
// callback still fires) and gains an internal fallback, so the composer
|
|
533
|
+
// runs with no state in the caller.
|
|
534
|
+
const valueControlled = valueProp !== undefined
|
|
535
|
+
const [value, setValue] = useControllableState<string>(valueProp, defaultValue ?? '', onValueChange)
|
|
536
|
+
|
|
537
|
+
const filesControlled = filesProp !== undefined
|
|
538
|
+
const supportsFiles = onFilesChange !== undefined || defaultFiles !== undefined
|
|
539
|
+
const [filesArr, setFiles] = useControllableState<File[]>(filesProp, defaultFiles ?? NO_FILES, onFilesChange)
|
|
540
|
+
|
|
541
|
+
// Queue is active only when a status exists (controlled or seeded) and a
|
|
542
|
+
// queue is wired — same opt-in shape as `supportsFiles`.
|
|
543
|
+
const statusEnabled = statusProp !== undefined || defaultStatus !== undefined
|
|
544
|
+
const [statusValue, setStatus] = useControllableState<InputMessageStatus>(
|
|
545
|
+
statusProp,
|
|
546
|
+
defaultStatus ?? 'idle',
|
|
547
|
+
onStatusChange,
|
|
548
|
+
)
|
|
549
|
+
const status = statusEnabled ? statusValue : undefined
|
|
550
|
+
const streaming = status === 'streaming'
|
|
551
|
+
|
|
552
|
+
const [queueArr, setQueue] = useControllableState<QueuedMessage[]>(
|
|
553
|
+
queueProp,
|
|
554
|
+
defaultQueue ?? NO_QUEUE,
|
|
555
|
+
onQueueChange,
|
|
556
|
+
)
|
|
557
|
+
// Always-current view of the queue, so enqueue/edit/remove/move read the
|
|
558
|
+
// latest value even if a handler closure is stale (e.g. two submits land
|
|
559
|
+
// before the controlled `queue` prop round-trips back).
|
|
560
|
+
const queueRef = useRef(queueArr)
|
|
561
|
+
queueRef.current = queueArr
|
|
562
|
+
const supportsQueue = statusEnabled && (onQueueChange !== undefined || defaultQueue !== undefined)
|
|
563
|
+
const [liveMsg, setLiveMsg] = useState('')
|
|
564
|
+
|
|
565
|
+
// Sent-message history navigation (readline-style). `historyIndex` is null
|
|
566
|
+
// when not browsing; `draftBeforeHistory` stashes the in-progress text so
|
|
567
|
+
// ArrowDown past the newest entry restores it.
|
|
568
|
+
const [historyIndex, setHistoryIndex] = useState<number | null>(null)
|
|
569
|
+
const draftBeforeHistory = useRef('')
|
|
570
|
+
|
|
571
|
+
// Suggested prompts. The list only shows while the draft is empty, and
|
|
572
|
+
// `activeSuggestion` is the highlighted row — focus never leaves the
|
|
573
|
+
// textarea (aria-activedescendant points at the highlighted option).
|
|
574
|
+
// Highlight state lives in the fluid-hover system so pointer and
|
|
575
|
+
// keyboard drive the same sliding bg-hover overlay (Dropdown's pattern):
|
|
576
|
+
// mouse movement resolves the nearest row, ↓/↑ set the index directly.
|
|
577
|
+
const suggestionsArr = useMemo(() => suggestions ?? [], [suggestions])
|
|
578
|
+
const suggestionsOpen = suggestionsArr.length > 0 && value === ''
|
|
579
|
+
const suggestionListRef = useRef<HTMLDivElement | null>(null)
|
|
580
|
+
// Pixel heights for the three collapsible regions (see useRegionHeight).
|
|
581
|
+
const [filesRegionRef, filesRegionHeight] = useRegionHeight()
|
|
582
|
+
const [queueRegionRef, queueRegionHeight] = useRegionHeight()
|
|
583
|
+
const [suggestionsRegionRef, suggestionsRegionHeight] = useRegionHeight()
|
|
584
|
+
const suggestionHover = useFluidHover(suggestionListRef)
|
|
585
|
+
const {
|
|
586
|
+
activeIndex: activeSuggestion,
|
|
587
|
+
setActiveIndex: setActiveSuggestion,
|
|
588
|
+
handlers: suggestionHandlers,
|
|
589
|
+
registerItem: registerSuggestion,
|
|
590
|
+
remeasure,
|
|
591
|
+
} = suggestionHover
|
|
592
|
+
|
|
593
|
+
// Rows stay registered while the list is closed, so their rects are from
|
|
594
|
+
// a hidden layout: re-measure on open. The highlight waits for it.
|
|
595
|
+
useEffect(() => {
|
|
596
|
+
if (suggestionsOpen) remeasure()
|
|
597
|
+
}, [suggestionsOpen, remeasure])
|
|
598
|
+
const suggestionListId = useId()
|
|
599
|
+
const ghostHintId = useId()
|
|
600
|
+
const showGhost = !!placeholderSuggestion && value === '' && !(dragOver && supportsFiles)
|
|
601
|
+
|
|
602
|
+
useEffect(() => {
|
|
603
|
+
if (!suggestionsOpen) setActiveSuggestion(null)
|
|
604
|
+
}, [suggestionsOpen, setActiveSuggestion])
|
|
605
|
+
|
|
606
|
+
// Fill a suggested prompt into the composer (Tab / Enter / click). Fills
|
|
607
|
+
// only — the user still reviews and sends.
|
|
608
|
+
const acceptSuggestion = useCallback(
|
|
609
|
+
(text: string) => {
|
|
610
|
+
setActiveSuggestion(null)
|
|
611
|
+
setHistoryIndex(null)
|
|
612
|
+
setValue(text)
|
|
613
|
+
requestAnimationFrame(() => {
|
|
614
|
+
const el = textareaRef.current
|
|
615
|
+
if (!el) return
|
|
616
|
+
el.focus()
|
|
617
|
+
el.setSelectionRange(el.value.length, el.value.length)
|
|
618
|
+
})
|
|
619
|
+
},
|
|
620
|
+
[setValue, setActiveSuggestion],
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
// Parsed line-height, cached per textarea element — getComputedStyle on
|
|
624
|
+
// every keystroke is needless work when the value only changes with font
|
|
625
|
+
// or zoom changes.
|
|
626
|
+
const lineHeightCache = useRef<{ el: HTMLTextAreaElement; value: number } | null>(null)
|
|
627
|
+
|
|
628
|
+
const resizeTextarea = useCallback(() => {
|
|
629
|
+
const el = textareaRef.current
|
|
630
|
+
if (!el) return
|
|
631
|
+
el.style.height = 'auto'
|
|
632
|
+
let cache = lineHeightCache.current
|
|
633
|
+
if (!cache || cache.el !== el) {
|
|
634
|
+
const lineHeight = parseFloat(getComputedStyle(el).lineHeight)
|
|
635
|
+
cache = { el, value: Number.isNaN(lineHeight) ? 20 : lineHeight }
|
|
636
|
+
lineHeightCache.current = cache
|
|
637
|
+
}
|
|
638
|
+
const min = cache.value * minRows
|
|
639
|
+
const max = cache.value * maxRows
|
|
640
|
+
const next = Math.min(Math.max(el.scrollHeight, min), max)
|
|
641
|
+
el.style.height = `${next}px`
|
|
642
|
+
el.style.overflowY = el.scrollHeight > max ? 'auto' : 'hidden'
|
|
643
|
+
}, [minRows, maxRows])
|
|
644
|
+
|
|
645
|
+
useIsoLayoutEffect(() => {
|
|
646
|
+
resizeTextarea()
|
|
647
|
+
}, [value, resizeTextarea])
|
|
648
|
+
|
|
649
|
+
// Re-measure when the textarea's width changes. The mount-time pass can
|
|
650
|
+
// run while an ancestor is still laid out at (near-)zero width — the
|
|
651
|
+
// wrapped placeholder then reads as many lines and pins the height at
|
|
652
|
+
// maxRows until the next value change. Width-gated so the observer
|
|
653
|
+
// doesn't loop on its own height writes.
|
|
654
|
+
useEffect(() => {
|
|
655
|
+
const el = textareaRef.current
|
|
656
|
+
if (!el || typeof ResizeObserver === 'undefined') return
|
|
657
|
+
let lastWidth = el.offsetWidth
|
|
658
|
+
const ro = new ResizeObserver(() => {
|
|
659
|
+
const width = el.offsetWidth
|
|
660
|
+
if (width === lastWidth) return
|
|
661
|
+
lastWidth = width
|
|
662
|
+
resizeTextarea()
|
|
663
|
+
})
|
|
664
|
+
ro.observe(el)
|
|
665
|
+
return () => ro.disconnect()
|
|
666
|
+
}, [resizeTextarea])
|
|
667
|
+
|
|
668
|
+
const trimmed = value.trim()
|
|
669
|
+
const canSend = !disabled && (trimmed.length > 0 || filesArr.length > 0)
|
|
670
|
+
|
|
671
|
+
// Edge = the box-shadow's 1px ring, recoloured in place per state so the
|
|
672
|
+
// stroke gains contrast without ever appearing to thicken (no second
|
|
673
|
+
// border band layered beside it). The drop (`0 1px 1px`) is kept so the
|
|
674
|
+
// composer holds its lift across states. Applied inline (not via a
|
|
675
|
+
// `shadow-*` utility, which mangles multi-layer arbitrary values) with the
|
|
676
|
+
// precedence drag > focus > hover; when none are active, the className's
|
|
677
|
+
// `shadow-surface-2` supplies the resting edge.
|
|
678
|
+
const EDGE_DROP = '0 1px 1px -0.5px var(--shadow-color)'
|
|
679
|
+
const edgeShadow = dragOver
|
|
680
|
+
? `0 0 0 1px var(--brand), ${EDGE_DROP}`
|
|
681
|
+
: focusVisible
|
|
682
|
+
? `0 0 0 1px color-mix(in oklab, var(--foreground) 20%, transparent), ${EDGE_DROP}`
|
|
683
|
+
: hovered && clickToFocus && !disabled
|
|
684
|
+
? `0 0 0 1px var(--border), ${EDGE_DROP}`
|
|
685
|
+
: undefined
|
|
686
|
+
|
|
687
|
+
const handleSend = useCallback(() => {
|
|
688
|
+
if (!canSend) return
|
|
689
|
+
setHistoryIndex(null)
|
|
690
|
+
// While the assistant is streaming, a submit enqueues instead of sending:
|
|
691
|
+
// snapshot the draft (text + currently-attached files) into a queue item,
|
|
692
|
+
// then clear the composer and keep focus.
|
|
693
|
+
if (streaming && supportsQueue) {
|
|
694
|
+
const item: QueuedMessage = {
|
|
695
|
+
id: crypto.randomUUID(),
|
|
696
|
+
text: trimmed,
|
|
697
|
+
files: filesArr,
|
|
698
|
+
}
|
|
699
|
+
setQueue([...queueRef.current, item])
|
|
700
|
+
setValue('')
|
|
701
|
+
if (supportsFiles) setFiles([])
|
|
702
|
+
requestAnimationFrame(() => textareaRef.current?.focus())
|
|
703
|
+
return
|
|
704
|
+
}
|
|
705
|
+
onSend?.(trimmed, filesArr)
|
|
706
|
+
// Local addition: an uncontrolled draft / file list clears itself, the
|
|
707
|
+
// way a controlled consumer clears its own state inside `onSend`.
|
|
708
|
+
if (!valueControlled) setValue('')
|
|
709
|
+
if (supportsFiles && !filesControlled) setFiles([])
|
|
710
|
+
}, [
|
|
711
|
+
canSend,
|
|
712
|
+
streaming,
|
|
713
|
+
supportsQueue,
|
|
714
|
+
onSend,
|
|
715
|
+
trimmed,
|
|
716
|
+
filesArr,
|
|
717
|
+
setQueue,
|
|
718
|
+
setValue,
|
|
719
|
+
supportsFiles,
|
|
720
|
+
setFiles,
|
|
721
|
+
valueControlled,
|
|
722
|
+
filesControlled,
|
|
723
|
+
])
|
|
724
|
+
|
|
725
|
+
// Stop halts the response. With the status uncontrolled the component owns
|
|
726
|
+
// that flag, so it flips itself to "idle" — the edge the auto-dispatch
|
|
727
|
+
// below watches for.
|
|
728
|
+
const statusControlled = statusProp !== undefined
|
|
729
|
+
const handleStop = useCallback(() => {
|
|
730
|
+
onStop?.()
|
|
731
|
+
if (!statusControlled) setStatus('idle')
|
|
732
|
+
}, [onStop, statusControlled, setStatus])
|
|
733
|
+
|
|
734
|
+
// Auto-dispatch: on the streaming → idle edge (whether the response
|
|
735
|
+
// finished on its own or the user pressed Stop), fire the head of the
|
|
736
|
+
// queue and drop it. The consumer is expected to set status back to
|
|
737
|
+
// "streaming" inside onSend, which re-arms this for the next item.
|
|
738
|
+
const prevStatusRef = useRef(status)
|
|
739
|
+
useEffect(() => {
|
|
740
|
+
const prev = prevStatusRef.current
|
|
741
|
+
prevStatusRef.current = status
|
|
742
|
+
if (!supportsQueue) return
|
|
743
|
+
if (prev === 'streaming' && status === 'idle' && queueArr.length > 0) {
|
|
744
|
+
const [next, ...rest] = queueArr
|
|
745
|
+
if (!next) return
|
|
746
|
+
setQueue(rest)
|
|
747
|
+
onSend?.(next.text, next.files, { queuedId: next.id })
|
|
748
|
+
setLiveMsg(`Message sent.${rest.length ? ` ${rest.length} still queued.` : ''}`)
|
|
749
|
+
}
|
|
750
|
+
}, [status, supportsQueue, queueArr, setQueue, onSend])
|
|
751
|
+
|
|
752
|
+
// ── Queue item actions ────────────────────────────────────────────
|
|
753
|
+
const editQueued = useCallback(
|
|
754
|
+
(item: QueuedMessage) => {
|
|
755
|
+
if (!supportsQueue) return
|
|
756
|
+
// Silent replace: pull the item out of the queue into the composer,
|
|
757
|
+
// overwriting any current draft. Re-sending re-queues it to the end.
|
|
758
|
+
setHistoryIndex(null)
|
|
759
|
+
setValue(item.text)
|
|
760
|
+
if (supportsFiles) {
|
|
761
|
+
setFiles(maxFiles != null ? item.files.slice(0, maxFiles) : item.files)
|
|
762
|
+
}
|
|
763
|
+
setQueue(queueRef.current.filter(q => q.id !== item.id))
|
|
764
|
+
requestAnimationFrame(() => {
|
|
765
|
+
const el = textareaRef.current
|
|
766
|
+
if (!el) return
|
|
767
|
+
el.focus()
|
|
768
|
+
el.setSelectionRange(el.value.length, el.value.length)
|
|
769
|
+
})
|
|
770
|
+
},
|
|
771
|
+
[supportsQueue, supportsFiles, setValue, setFiles, maxFiles, setQueue],
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
const removeQueued = useCallback(
|
|
775
|
+
(item: QueuedMessage) => setQueue(queueRef.current.filter(q => q.id !== item.id)),
|
|
776
|
+
[setQueue],
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
const moveQueued = useCallback(
|
|
780
|
+
(item: QueuedMessage, dir: -1 | 1) => {
|
|
781
|
+
const cur = queueRef.current
|
|
782
|
+
const i = cur.findIndex(q => q.id === item.id)
|
|
783
|
+
const j = i + dir
|
|
784
|
+
if (i < 0 || j < 0 || j >= cur.length) return
|
|
785
|
+
const next = [...cur]
|
|
786
|
+
const a = next[i]
|
|
787
|
+
const b = next[j]
|
|
788
|
+
if (!a || !b) return
|
|
789
|
+
next[i] = b
|
|
790
|
+
next[j] = a
|
|
791
|
+
setQueue(next)
|
|
792
|
+
},
|
|
793
|
+
[setQueue],
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
// Send button morph: Stop (streaming + empty draft) → Queue (streaming +
|
|
797
|
+
// draft) → Send (idle). Send and Queue share the arrow-up glyph; only the
|
|
798
|
+
// Stop⇄arrow swap animates. An uncontrolled status can always stop.
|
|
799
|
+
const canStop = onStop !== undefined || !statusControlled
|
|
800
|
+
const buttonMode: 'send' | 'queue' | 'stop' = !streaming
|
|
801
|
+
? 'send'
|
|
802
|
+
: canSend && supportsQueue
|
|
803
|
+
? 'queue'
|
|
804
|
+
: canStop
|
|
805
|
+
? 'stop'
|
|
806
|
+
: 'send'
|
|
807
|
+
const buttonLabel = buttonMode === 'stop' ? 'Stop' : buttonMode === 'queue' ? 'Queue message' : sendLabel
|
|
808
|
+
|
|
809
|
+
const setCaretEnd = useCallback(() => {
|
|
810
|
+
requestAnimationFrame(() => {
|
|
811
|
+
const el = textareaRef.current
|
|
812
|
+
if (el) el.setSelectionRange(el.value.length, el.value.length)
|
|
813
|
+
})
|
|
814
|
+
}, [])
|
|
815
|
+
|
|
816
|
+
const handleKeyDown = useCallback(
|
|
817
|
+
(e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
|
818
|
+
if (e.nativeEvent.isComposing) return
|
|
819
|
+
|
|
820
|
+
// Suggested prompts: plain ArrowDown moves the highlight into / down
|
|
821
|
+
// the list, ArrowUp walks it back up (then out, returning to plain
|
|
822
|
+
// textarea behavior), Enter fills the highlighted prompt, Escape
|
|
823
|
+
// drops the highlight. With no highlight, ArrowUp still falls through
|
|
824
|
+
// to history recall below.
|
|
825
|
+
if (suggestionsOpen && !e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey) {
|
|
826
|
+
if (e.key === 'ArrowDown') {
|
|
827
|
+
e.preventDefault()
|
|
828
|
+
setActiveSuggestion(prev => (prev == null ? 0 : Math.min(prev + 1, suggestionsArr.length - 1)))
|
|
829
|
+
return
|
|
830
|
+
}
|
|
831
|
+
if (activeSuggestion != null) {
|
|
832
|
+
if (e.key === 'ArrowUp') {
|
|
833
|
+
e.preventDefault()
|
|
834
|
+
setActiveSuggestion(activeSuggestion === 0 ? null : activeSuggestion - 1)
|
|
835
|
+
return
|
|
836
|
+
}
|
|
837
|
+
if (e.key === 'Enter') {
|
|
838
|
+
e.preventDefault()
|
|
839
|
+
const picked = suggestionsArr[activeSuggestion]
|
|
840
|
+
if (picked !== undefined) acceptSuggestion(picked)
|
|
841
|
+
return
|
|
842
|
+
}
|
|
843
|
+
if (e.key === 'Escape') {
|
|
844
|
+
e.preventDefault()
|
|
845
|
+
setActiveSuggestion(null)
|
|
846
|
+
return
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// Tab fills the suggested placeholder prompt into the empty composer
|
|
852
|
+
// (Shift+Tab still moves focus backward, and once the draft is
|
|
853
|
+
// non-empty Tab resumes normal focus traversal).
|
|
854
|
+
if (e.key === 'Tab' && !e.shiftKey && placeholderSuggestion && value === '') {
|
|
855
|
+
e.preventDefault()
|
|
856
|
+
acceptSuggestion(placeholderSuggestion)
|
|
857
|
+
return
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Readline-style history. Only plain ArrowUp/ArrowDown navigate (no
|
|
861
|
+
// modifiers), and only when the caret is on the first/last line so
|
|
862
|
+
// multi-line editing still works normally.
|
|
863
|
+
if (
|
|
864
|
+
history.length > 0 &&
|
|
865
|
+
(e.key === 'ArrowUp' || e.key === 'ArrowDown') &&
|
|
866
|
+
!e.shiftKey &&
|
|
867
|
+
!e.altKey &&
|
|
868
|
+
!e.metaKey &&
|
|
869
|
+
!e.ctrlKey
|
|
870
|
+
) {
|
|
871
|
+
const el = e.currentTarget
|
|
872
|
+
const caret = el.selectionStart ?? 0
|
|
873
|
+
const end = el.selectionEnd ?? caret
|
|
874
|
+
if (e.key === 'ArrowUp' && !value.slice(0, caret).includes('\n')) {
|
|
875
|
+
const start = historyIndex == null ? history.length : historyIndex
|
|
876
|
+
if (start > 0) {
|
|
877
|
+
e.preventDefault()
|
|
878
|
+
if (historyIndex == null) draftBeforeHistory.current = value
|
|
879
|
+
const ni = start - 1
|
|
880
|
+
setHistoryIndex(ni)
|
|
881
|
+
setValue(history[ni] ?? '')
|
|
882
|
+
setCaretEnd()
|
|
883
|
+
}
|
|
884
|
+
return
|
|
885
|
+
}
|
|
886
|
+
if (e.key === 'ArrowDown' && historyIndex != null && !value.slice(end).includes('\n')) {
|
|
887
|
+
e.preventDefault()
|
|
888
|
+
const ni = historyIndex + 1
|
|
889
|
+
if (ni >= history.length) {
|
|
890
|
+
setHistoryIndex(null)
|
|
891
|
+
setValue(draftBeforeHistory.current)
|
|
892
|
+
} else {
|
|
893
|
+
setHistoryIndex(ni)
|
|
894
|
+
setValue(history[ni] ?? '')
|
|
895
|
+
}
|
|
896
|
+
setCaretEnd()
|
|
897
|
+
return
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
902
|
+
e.preventDefault()
|
|
903
|
+
handleSend()
|
|
904
|
+
}
|
|
905
|
+
},
|
|
906
|
+
[
|
|
907
|
+
history,
|
|
908
|
+
value,
|
|
909
|
+
historyIndex,
|
|
910
|
+
setValue,
|
|
911
|
+
setCaretEnd,
|
|
912
|
+
handleSend,
|
|
913
|
+
suggestionsOpen,
|
|
914
|
+
suggestionsArr,
|
|
915
|
+
activeSuggestion,
|
|
916
|
+
setActiveSuggestion,
|
|
917
|
+
acceptSuggestion,
|
|
918
|
+
placeholderSuggestion,
|
|
919
|
+
],
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
const handleContainerMouseDown = useCallback(
|
|
923
|
+
(e: ReactMouseEvent<HTMLDivElement>) => {
|
|
924
|
+
if (!clickToFocus || disabled) return
|
|
925
|
+
const target = e.target as HTMLElement
|
|
926
|
+
if (target === textareaRef.current) return
|
|
927
|
+
if (target.closest('button, a, input, select, textarea, [contenteditable], [role="button"], [data-im-queue]')) {
|
|
928
|
+
return
|
|
929
|
+
}
|
|
930
|
+
e.preventDefault()
|
|
931
|
+
textareaRef.current?.focus()
|
|
932
|
+
},
|
|
933
|
+
[clickToFocus, disabled],
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
// ── File helpers ──────────────────────────────────────────────────
|
|
937
|
+
const acceptTokens = useMemo(
|
|
938
|
+
() =>
|
|
939
|
+
accept
|
|
940
|
+
.split(',')
|
|
941
|
+
.map(s => s.trim())
|
|
942
|
+
.filter(Boolean),
|
|
943
|
+
[accept],
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
const matchesAccept = useCallback(
|
|
947
|
+
(file: File) =>
|
|
948
|
+
acceptTokens.some(token => {
|
|
949
|
+
if (token.endsWith('/*')) return file.type.startsWith(token.slice(0, -1))
|
|
950
|
+
if (token.startsWith('.')) return file.name.toLowerCase().endsWith(token.toLowerCase())
|
|
951
|
+
return file.type === token
|
|
952
|
+
}),
|
|
953
|
+
[acceptTokens],
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
const addFiles = useCallback(
|
|
957
|
+
(incoming: File[]) => {
|
|
958
|
+
if (!supportsFiles) return
|
|
959
|
+
// Identity key for dedup: name + size + lastModified is unique enough
|
|
960
|
+
// to catch "user dropped the same file twice" without false positives
|
|
961
|
+
// on legitimately distinct files (different bytes ⇒ different size).
|
|
962
|
+
const fingerprint = (f: File) => `${f.name}-${f.size}-${f.lastModified}`
|
|
963
|
+
const existing = new Set(filesArr.map(fingerprint))
|
|
964
|
+
const accepted: File[] = []
|
|
965
|
+
for (const f of incoming) {
|
|
966
|
+
if (!matchesAccept(f)) continue
|
|
967
|
+
const fp = fingerprint(f)
|
|
968
|
+
if (existing.has(fp)) continue
|
|
969
|
+
existing.add(fp)
|
|
970
|
+
accepted.push(f)
|
|
971
|
+
}
|
|
972
|
+
if (!accepted.length) return
|
|
973
|
+
const next = [...filesArr, ...accepted]
|
|
974
|
+
setFiles(maxFiles != null ? next.slice(0, maxFiles) : next)
|
|
975
|
+
},
|
|
976
|
+
[supportsFiles, setFiles, filesArr, matchesAccept, maxFiles],
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
const removeFile = useCallback(
|
|
980
|
+
(idx: number) => {
|
|
981
|
+
if (!supportsFiles) return
|
|
982
|
+
setFiles(filesArr.filter((_, i) => i !== idx))
|
|
983
|
+
},
|
|
984
|
+
[supportsFiles, setFiles, filesArr],
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
const openFilePicker = useCallback(
|
|
988
|
+
(overrideAccept?: string) => {
|
|
989
|
+
const el = fileInputRef.current
|
|
990
|
+
if (!el) return
|
|
991
|
+
// Temporarily narrow `accept` for this invocation (e.g. "image/*").
|
|
992
|
+
// Reset after the click so subsequent native invocations still honor
|
|
993
|
+
// the component-level accept.
|
|
994
|
+
if (overrideAccept) {
|
|
995
|
+
el.accept = overrideAccept
|
|
996
|
+
el.click()
|
|
997
|
+
// Restore on next tick — the picker dialog reads `accept` synchronously.
|
|
998
|
+
queueMicrotask(() => {
|
|
999
|
+
if (fileInputRef.current) fileInputRef.current.accept = accept
|
|
1000
|
+
})
|
|
1001
|
+
return
|
|
1002
|
+
}
|
|
1003
|
+
el.click()
|
|
1004
|
+
},
|
|
1005
|
+
[accept],
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
// ── Slot rendering ────────────────────────────────────────────────
|
|
1009
|
+
const slotCtx = useMemo<InputMessageSlotContext>(
|
|
1010
|
+
() => ({ openFilePicker, files: filesArr }),
|
|
1011
|
+
[openFilePicker, filesArr],
|
|
1012
|
+
)
|
|
1013
|
+
const leftContent = typeof leftSlot === 'function' ? leftSlot(slotCtx) : leftSlot
|
|
1014
|
+
const rightContent = typeof rightSlot === 'function' ? rightSlot(slotCtx) : rightSlot
|
|
1015
|
+
|
|
1016
|
+
// ── Drag-and-drop ────────────────────────────────────────────────
|
|
1017
|
+
const handleDragOver = useCallback(
|
|
1018
|
+
(e: ReactDragEvent<HTMLDivElement>) => {
|
|
1019
|
+
if (!supportsFiles || disabled) return
|
|
1020
|
+
// Only treat as a file drag — text/HTML drags shouldn't trigger.
|
|
1021
|
+
if (!Array.from(e.dataTransfer.types).includes('Files')) return
|
|
1022
|
+
e.preventDefault()
|
|
1023
|
+
e.dataTransfer.dropEffect = 'copy'
|
|
1024
|
+
setDragOver(true)
|
|
1025
|
+
},
|
|
1026
|
+
[supportsFiles, disabled],
|
|
1027
|
+
)
|
|
1028
|
+
|
|
1029
|
+
const handleDragLeave = useCallback((e: ReactDragEvent<HTMLDivElement>) => {
|
|
1030
|
+
const wrapper = e.currentTarget
|
|
1031
|
+
const next = e.relatedTarget as Node | null
|
|
1032
|
+
if (next && wrapper.contains(next)) return
|
|
1033
|
+
setDragOver(false)
|
|
1034
|
+
}, [])
|
|
1035
|
+
|
|
1036
|
+
const handleDrop = useCallback(
|
|
1037
|
+
(e: ReactDragEvent<HTMLDivElement>) => {
|
|
1038
|
+
e.preventDefault()
|
|
1039
|
+
setDragOver(false)
|
|
1040
|
+
if (!supportsFiles || disabled) return
|
|
1041
|
+
addFiles(Array.from(e.dataTransfer.files))
|
|
1042
|
+
},
|
|
1043
|
+
[supportsFiles, disabled, addFiles],
|
|
1044
|
+
)
|
|
1045
|
+
|
|
1046
|
+
const handleFileInputChange = useCallback(
|
|
1047
|
+
(e: ChangeEvent<HTMLInputElement>) => {
|
|
1048
|
+
if (!e.target.files) return
|
|
1049
|
+
addFiles(Array.from(e.target.files))
|
|
1050
|
+
e.target.value = '' // Allow re-selecting the same file.
|
|
1051
|
+
},
|
|
1052
|
+
[addFiles],
|
|
1053
|
+
)
|
|
1054
|
+
|
|
1055
|
+
const composer = (
|
|
1056
|
+
<div
|
|
1057
|
+
ref={ref}
|
|
1058
|
+
onMouseDown={handleContainerMouseDown}
|
|
1059
|
+
onDragOver={handleDragOver}
|
|
1060
|
+
onDragLeave={handleDragLeave}
|
|
1061
|
+
onDrop={handleDrop}
|
|
1062
|
+
className={cn(
|
|
1063
|
+
// The edge is the box-shadow's hairline ring (from surface-2), not a
|
|
1064
|
+
// border. State changes recolor that same 1px ring in place rather
|
|
1065
|
+
// than layering a second colored border beside it — so hover / focus
|
|
1066
|
+
// bump *contrast* without ever appearing to thicken the stroke.
|
|
1067
|
+
'flex flex-col gap-1 p-2 transition-[box-shadow,color] duration-fast',
|
|
1068
|
+
surfaceClasses(2, 2),
|
|
1069
|
+
shape.container,
|
|
1070
|
+
clickToFocus && !disabled && 'cursor-text',
|
|
1071
|
+
disabled && 'opacity-50 pointer-events-none',
|
|
1072
|
+
className,
|
|
1073
|
+
)}
|
|
1074
|
+
style={edgeShadow ? { boxShadow: edgeShadow, ...style } : style}
|
|
1075
|
+
onMouseEnter={() => setHovered(true)}
|
|
1076
|
+
onMouseLeave={() => setHovered(false)}
|
|
1077
|
+
{...props}
|
|
1078
|
+
>
|
|
1079
|
+
<SurfaceProvider value={2}>
|
|
1080
|
+
{supportsFiles && (
|
|
1081
|
+
<input
|
|
1082
|
+
ref={fileInputRef}
|
|
1083
|
+
type="file"
|
|
1084
|
+
accept={accept}
|
|
1085
|
+
multiple={maxFiles == null || maxFiles > 1}
|
|
1086
|
+
className="hidden"
|
|
1087
|
+
onChange={handleFileInputChange}
|
|
1088
|
+
aria-hidden="true"
|
|
1089
|
+
tabIndex={-1}
|
|
1090
|
+
/>
|
|
1091
|
+
)}
|
|
1092
|
+
|
|
1093
|
+
{/* Attached files preview row — sits above the textarea.
|
|
1094
|
+
The outer motion.div animates the row's height (collapsing the
|
|
1095
|
+
whole component height) when files appear / disappear.
|
|
1096
|
+
The inner `mode="popLayout"` AnimatePresence pulls a removing
|
|
1097
|
+
tile out of layout flow so siblings can slide into the gap
|
|
1098
|
+
without fighting its exit anim. Keys are purely file-identity
|
|
1099
|
+
(no index) so removing the first file doesn't re-key — and
|
|
1100
|
+
remount — every surviving sibling. */}
|
|
1101
|
+
<AnimatePresence initial={false}>
|
|
1102
|
+
{filesArr.length > 0 && (
|
|
1103
|
+
<motion.div
|
|
1104
|
+
key="preview-row"
|
|
1105
|
+
initial={{ height: 0, opacity: 0 }}
|
|
1106
|
+
animate={{ height: filesRegionHeight ?? 0, opacity: 1 }}
|
|
1107
|
+
exit={{ height: 0, opacity: 0 }}
|
|
1108
|
+
transition={{ ...spring.moderate, bounce: 0 }}
|
|
1109
|
+
className="overflow-hidden"
|
|
1110
|
+
>
|
|
1111
|
+
<div ref={filesRegionRef} className="flex flex-wrap gap-2 pb-1">
|
|
1112
|
+
<AnimatePresence initial={false} mode="popLayout">
|
|
1113
|
+
{filesArr.map((file, i) => (
|
|
1114
|
+
<FilePreviewTile
|
|
1115
|
+
key={`${file.name}-${file.size}-${file.lastModified}`}
|
|
1116
|
+
file={file}
|
|
1117
|
+
onRemove={() => removeFile(i)}
|
|
1118
|
+
size={filePreviewSize}
|
|
1119
|
+
/>
|
|
1120
|
+
))}
|
|
1121
|
+
</AnimatePresence>
|
|
1122
|
+
</div>
|
|
1123
|
+
</motion.div>
|
|
1124
|
+
)}
|
|
1125
|
+
</AnimatePresence>
|
|
1126
|
+
|
|
1127
|
+
{/* Queued messages — reorderable rows above the textarea. The outer
|
|
1128
|
+
motion.div collapses the region height when the queue empties;
|
|
1129
|
+
the Reorder.Group handles drag-reorder (top = next to dispatch)
|
|
1130
|
+
and AnimatePresence handles per-row enter/exit. */}
|
|
1131
|
+
{supportsQueue && showQueue && (
|
|
1132
|
+
<AnimatePresence initial={false}>
|
|
1133
|
+
{queueArr.length > 0 && (
|
|
1134
|
+
<motion.div
|
|
1135
|
+
key="queue-row"
|
|
1136
|
+
initial={{ height: 0, opacity: 0 }}
|
|
1137
|
+
animate={{ height: queueRegionHeight ?? 0, opacity: 1 }}
|
|
1138
|
+
exit={{ height: 0, opacity: 0 }}
|
|
1139
|
+
transition={{ ...spring.moderate, bounce: 0 }}
|
|
1140
|
+
className="overflow-hidden"
|
|
1141
|
+
>
|
|
1142
|
+
<Reorder.Group
|
|
1143
|
+
ref={queueRegionRef}
|
|
1144
|
+
axis="y"
|
|
1145
|
+
values={queueArr}
|
|
1146
|
+
onReorder={(next: QueuedMessage[]) => setQueue(next)}
|
|
1147
|
+
data-im-queue
|
|
1148
|
+
className="flex flex-col gap-1 pb-1"
|
|
1149
|
+
>
|
|
1150
|
+
<AnimatePresence initial={false}>
|
|
1151
|
+
{queueArr.map((item, i) => (
|
|
1152
|
+
<QueuedRow
|
|
1153
|
+
key={item.id}
|
|
1154
|
+
item={item}
|
|
1155
|
+
index={i}
|
|
1156
|
+
total={queueArr.length}
|
|
1157
|
+
reduceMotion={reduceMotion}
|
|
1158
|
+
isTouch={isTouch}
|
|
1159
|
+
onEdit={editQueued}
|
|
1160
|
+
onRemove={removeQueued}
|
|
1161
|
+
onMove={moveQueued}
|
|
1162
|
+
/>
|
|
1163
|
+
))}
|
|
1164
|
+
</AnimatePresence>
|
|
1165
|
+
</Reorder.Group>
|
|
1166
|
+
</motion.div>
|
|
1167
|
+
)}
|
|
1168
|
+
</AnimatePresence>
|
|
1169
|
+
)}
|
|
1170
|
+
|
|
1171
|
+
<div className="relative">
|
|
1172
|
+
<textarea
|
|
1173
|
+
ref={textareaRef}
|
|
1174
|
+
value={value}
|
|
1175
|
+
onChange={e => {
|
|
1176
|
+
// Real typing exits history mode (recall sets the value
|
|
1177
|
+
// programmatically, which doesn't fire onChange) and drops
|
|
1178
|
+
// any suggestion highlight.
|
|
1179
|
+
setHistoryIndex(null)
|
|
1180
|
+
setActiveSuggestion(null)
|
|
1181
|
+
setValue(e.target.value)
|
|
1182
|
+
}}
|
|
1183
|
+
onKeyDown={handleKeyDown}
|
|
1184
|
+
// Compose the consumer's textareaProps handlers with the internal
|
|
1185
|
+
// focus-visible tracking (the spread below would otherwise
|
|
1186
|
+
// overwrite these).
|
|
1187
|
+
onFocus={e => {
|
|
1188
|
+
if (e.target.matches(':focus-visible')) setFocusVisible(true)
|
|
1189
|
+
textareaProps?.onFocus?.(e)
|
|
1190
|
+
}}
|
|
1191
|
+
onBlur={e => {
|
|
1192
|
+
setFocusVisible(false)
|
|
1193
|
+
setActiveSuggestion(null)
|
|
1194
|
+
textareaProps?.onBlur?.(e)
|
|
1195
|
+
}}
|
|
1196
|
+
placeholder={
|
|
1197
|
+
dragOver && supportsFiles
|
|
1198
|
+
? 'Drop files here to add to chat'
|
|
1199
|
+
: placeholderSuggestion
|
|
1200
|
+
? undefined // the ghost overlay below renders it
|
|
1201
|
+
: placeholder
|
|
1202
|
+
}
|
|
1203
|
+
disabled={disabled}
|
|
1204
|
+
rows={minRows}
|
|
1205
|
+
aria-label={textareaProps?.['aria-label'] ?? 'Message'}
|
|
1206
|
+
aria-describedby={
|
|
1207
|
+
[showGhost ? ghostHintId : null, textareaDescribedBy].filter(Boolean).join(' ') || undefined
|
|
1208
|
+
}
|
|
1209
|
+
aria-activedescendant={activeSuggestion != null ? `${suggestionListId}-${activeSuggestion}` : undefined}
|
|
1210
|
+
className={cn(
|
|
1211
|
+
'w-full resize-none rounded-none bg-transparent outline-none',
|
|
1212
|
+
'text-foreground placeholder:text-muted-foreground',
|
|
1213
|
+
compactStep ? 'text-subtitle-compact leading-4.5 px-1.5 py-1.5' : 'text-subtitle leading-5 px-2 py-2',
|
|
1214
|
+
'weight-normal',
|
|
1215
|
+
)}
|
|
1216
|
+
{...restTextareaProps}
|
|
1217
|
+
/>
|
|
1218
|
+
{/* Ghost placeholder: the suggested prompt with a Tab keycap. A
|
|
1219
|
+
real overlay (not the native placeholder) so the keycap can
|
|
1220
|
+
render inline after the text; typography mirrors the textarea
|
|
1221
|
+
exactly so it sits where typed text will. */}
|
|
1222
|
+
{showGhost && (
|
|
1223
|
+
<div
|
|
1224
|
+
aria-hidden="true"
|
|
1225
|
+
className={cn(
|
|
1226
|
+
'pointer-events-none absolute inset-0 overflow-hidden text-muted-foreground',
|
|
1227
|
+
// Mirror the textarea's step typography exactly so the ghost
|
|
1228
|
+
// sits where typed text will.
|
|
1229
|
+
compactStep ? 'text-subtitle-compact leading-4.5 px-1.5 py-1.5' : 'text-subtitle leading-5 px-2 py-2',
|
|
1230
|
+
'weight-normal',
|
|
1231
|
+
)}
|
|
1232
|
+
>
|
|
1233
|
+
{/* One flex line: a suggestion longer than the field truncates
|
|
1234
|
+
with an ellipsis instead of wrapping into the clip (the
|
|
1235
|
+
overlay is inset-0 over a possibly single-row textarea),
|
|
1236
|
+
and the Tab chip never gets cut. */}
|
|
1237
|
+
<span className="flex max-w-full items-center gap-1.5">
|
|
1238
|
+
<span className="min-w-0 truncate">{placeholderSuggestion}</span>
|
|
1239
|
+
<kbd
|
|
1240
|
+
className={cn(
|
|
1241
|
+
'inline-flex shrink-0 -translate-y-px items-center rounded-box border border-border bg-background px-1 font-sans text-muted-foreground',
|
|
1242
|
+
compactStep ? 'h-4 text-micro-compact' : 'h-[18px] text-micro',
|
|
1243
|
+
)}
|
|
1244
|
+
>
|
|
1245
|
+
Tab
|
|
1246
|
+
</kbd>
|
|
1247
|
+
</span>
|
|
1248
|
+
</div>
|
|
1249
|
+
)}
|
|
1250
|
+
{showGhost && (
|
|
1251
|
+
<span id={ghostHintId} className="sr-only">
|
|
1252
|
+
Suggested prompt: {placeholderSuggestion}. Press Tab to fill the composer with it.
|
|
1253
|
+
</span>
|
|
1254
|
+
)}
|
|
1255
|
+
</div>
|
|
1256
|
+
<div
|
|
1257
|
+
className={cn(
|
|
1258
|
+
'flex items-center justify-between',
|
|
1259
|
+
// The footer's controls sit one notch below the composer's step:
|
|
1260
|
+
// slot content is consumer-authored (usually sm/icon-sm pinned
|
|
1261
|
+
// Buttons), so the compact step scales any button in the row —
|
|
1262
|
+
// send button included — down to 24px via a scoped override.
|
|
1263
|
+
compactStep ? 'gap-1.5 [&_button]:h-6 [&_button.w-7]:w-6 [&_button]:text-caption-compact' : 'gap-2',
|
|
1264
|
+
)}
|
|
1265
|
+
>
|
|
1266
|
+
<div className="flex items-center gap-1.5 min-w-0">{leftContent}</div>
|
|
1267
|
+
<div className="flex items-center gap-1.5 shrink-0">
|
|
1268
|
+
{rightContent}
|
|
1269
|
+
<Button
|
|
1270
|
+
type="button"
|
|
1271
|
+
variant="primary"
|
|
1272
|
+
size="icon-sm"
|
|
1273
|
+
onClick={buttonMode === 'stop' ? handleStop : handleSend}
|
|
1274
|
+
disabled={buttonMode === 'stop' ? disabled : !canSend}
|
|
1275
|
+
aria-label={buttonLabel}
|
|
1276
|
+
>
|
|
1277
|
+
<AnimatePresence mode="wait" initial={false}>
|
|
1278
|
+
<motion.span
|
|
1279
|
+
key={buttonMode === 'stop' ? 'stop' : 'arrow'}
|
|
1280
|
+
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
|
|
1281
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
1282
|
+
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6, transition: spring.fast.exit }}
|
|
1283
|
+
transition={spring.fast}
|
|
1284
|
+
className="flex items-center justify-center leading-none"
|
|
1285
|
+
>
|
|
1286
|
+
{buttonMode === 'stop' ? (
|
|
1287
|
+
<span className="h-3 w-3 rounded-glyph bg-current" />
|
|
1288
|
+
) : (
|
|
1289
|
+
// Override icon-sm's small 14px svg — the send glyph reads
|
|
1290
|
+
// better a touch larger. `size` matches the attribute to
|
|
1291
|
+
// the CSS so the svg box stays centered.
|
|
1292
|
+
<ArrowUpIcon
|
|
1293
|
+
size={compactStep ? 15 : 19}
|
|
1294
|
+
className={cn('block', compactStep ? '!h-[15px] !w-[15px]' : '!h-[19px] !w-[19px]')}
|
|
1295
|
+
/>
|
|
1296
|
+
)}
|
|
1297
|
+
</motion.span>
|
|
1298
|
+
</AnimatePresence>
|
|
1299
|
+
</Button>
|
|
1300
|
+
</div>
|
|
1301
|
+
</div>
|
|
1302
|
+
|
|
1303
|
+
{/* Suggested prompts — a listbox under the action bar, shown while
|
|
1304
|
+
the draft is empty. ↓/↑ move the highlight without moving focus
|
|
1305
|
+
(the textarea's aria-activedescendant tracks it); Enter or click
|
|
1306
|
+
fills the composer. The outer motion.div collapses the region's
|
|
1307
|
+
height once typing hides the list; -mx-2 cancels the container
|
|
1308
|
+
padding so the divider runs the composer's full width. Pointer
|
|
1309
|
+
and keyboard share one bg-hover overlay that springs between
|
|
1310
|
+
row rects (fluid-hover, same as Dropdown). */}
|
|
1311
|
+
{suggestionsArr.length > 0 && (
|
|
1312
|
+
<AnimatePresence initial={false}>
|
|
1313
|
+
{suggestionsOpen && (
|
|
1314
|
+
<motion.div
|
|
1315
|
+
key="suggestions"
|
|
1316
|
+
initial={{ height: 0, opacity: 0 }}
|
|
1317
|
+
animate={{ height: suggestionsRegionHeight ?? 0, opacity: 1 }}
|
|
1318
|
+
// Height-only exit: the region clips shut bottom-up under
|
|
1319
|
+
// overflow-hidden, so the divider holds its place until the
|
|
1320
|
+
// space actually closes (a simultaneous opacity fade made it
|
|
1321
|
+
// vanish mid-collapse, which read as a height glitch).
|
|
1322
|
+
exit={{ height: 0 }}
|
|
1323
|
+
transition={{ ...spring.moderate, bounce: 0 }}
|
|
1324
|
+
// -mt-1 cancels the container's gap-1 above this region and
|
|
1325
|
+
// the listbox's mt-2 restores it INSIDE the collapsible
|
|
1326
|
+
// area — otherwise that 4px gap sits outside the height
|
|
1327
|
+
// animation and snaps away only at unmount.
|
|
1328
|
+
className="-mx-2 -mt-1 overflow-hidden"
|
|
1329
|
+
>
|
|
1330
|
+
<div
|
|
1331
|
+
ref={el => {
|
|
1332
|
+
suggestionListRef.current = el
|
|
1333
|
+
suggestionsRegionRef(el)
|
|
1334
|
+
}}
|
|
1335
|
+
role="listbox"
|
|
1336
|
+
id={suggestionListId}
|
|
1337
|
+
aria-label="Suggested prompts"
|
|
1338
|
+
onMouseEnter={suggestionHandlers.onMouseEnter}
|
|
1339
|
+
onMouseMove={suggestionHandlers.onMouseMove}
|
|
1340
|
+
onMouseLeave={suggestionHandlers.onMouseLeave}
|
|
1341
|
+
onClick={suggestionHandlers.onClick}
|
|
1342
|
+
className="relative mt-2 flex flex-col border-t border-border/60 px-1.5 pt-1.5"
|
|
1343
|
+
>
|
|
1344
|
+
{/* Hover / keyboard highlight: one overlay sliding
|
|
1345
|
+
between rows instead of per-row backgrounds. */}
|
|
1346
|
+
<FluidHoverHighlight hover={suggestionHover} className={shape.bg} />
|
|
1347
|
+
{suggestionsArr.map((s, i) => (
|
|
1348
|
+
<SuggestionRow
|
|
1349
|
+
key={`${s}-${i}`}
|
|
1350
|
+
text={s}
|
|
1351
|
+
index={i}
|
|
1352
|
+
active={i === activeSuggestion}
|
|
1353
|
+
keyHint={i === 0 && activeSuggestion == null}
|
|
1354
|
+
optionId={`${suggestionListId}-${i}`}
|
|
1355
|
+
registerItem={registerSuggestion}
|
|
1356
|
+
onSelect={() => acceptSuggestion(s)}
|
|
1357
|
+
/>
|
|
1358
|
+
))}
|
|
1359
|
+
</div>
|
|
1360
|
+
</motion.div>
|
|
1361
|
+
)}
|
|
1362
|
+
</AnimatePresence>
|
|
1363
|
+
)}
|
|
1364
|
+
{/* Politely announces auto-dispatch of queued messages. */}
|
|
1365
|
+
<span className="sr-only" role="status" aria-live="polite">
|
|
1366
|
+
{liveMsg}
|
|
1367
|
+
</span>
|
|
1368
|
+
</SurfaceProvider>
|
|
1369
|
+
</div>
|
|
1370
|
+
)
|
|
1371
|
+
|
|
1372
|
+
// A size prop pins the whole composer — inner buttons, rows and queued
|
|
1373
|
+
// messages included — to one ladder step (matches InputGroup).
|
|
1374
|
+
return size ? <SizeProvider size={size}>{composer}</SizeProvider> : composer
|
|
1375
|
+
},
|
|
1376
|
+
)
|
|
1377
|
+
|
|
1378
|
+
InputMessage.displayName = 'InputMessage'
|
|
1379
|
+
|
|
1380
|
+
export { FileThumbnail } from './file-thumbnail'
|
|
1381
|
+
export type { InputMessageProps, InputMessageSlotContext, InputMessageStatus, QueuedMessage }
|
|
1382
|
+
export { InputMessage }
|
|
1383
|
+
export default InputMessage
|