@womp/kookie-ui 0.4.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (548) hide show
  1. package/components.css +783 -783
  2. package/dist/cjs/components/_internal/base-menu.utils.d.ts +48 -0
  3. package/dist/cjs/components/_internal/base-menu.utils.d.ts.map +1 -0
  4. package/dist/cjs/components/_internal/base-menu.utils.js +2 -0
  5. package/dist/cjs/components/_internal/base-menu.utils.js.map +7 -0
  6. package/dist/cjs/components/_internal/dropdown-menu-drill-down.d.ts +77 -59
  7. package/dist/cjs/components/_internal/dropdown-menu-drill-down.d.ts.map +1 -1
  8. package/dist/cjs/components/_internal/dropdown-menu-drill-down.js +1 -1
  9. package/dist/cjs/components/_internal/dropdown-menu-drill-down.js.map +4 -4
  10. package/dist/cjs/components/_internal/menu-context.d.ts +7 -3
  11. package/dist/cjs/components/_internal/menu-context.d.ts.map +1 -1
  12. package/dist/cjs/components/_internal/menu-context.js +1 -1
  13. package/dist/cjs/components/_internal/menu-context.js.map +4 -4
  14. package/dist/cjs/components/_internal/shell-bottom.js +1 -1
  15. package/dist/cjs/components/_internal/shell-bottom.js.map +2 -2
  16. package/dist/cjs/components/_internal/shell-inspector.js +1 -1
  17. package/dist/cjs/components/_internal/shell-inspector.js.map +2 -2
  18. package/dist/cjs/components/_internal/shell-sidebar.js +1 -1
  19. package/dist/cjs/components/_internal/shell-sidebar.js.map +2 -2
  20. package/dist/cjs/components/accordion.js +1 -1
  21. package/dist/cjs/components/alert-dialog.js +1 -1
  22. package/dist/cjs/components/alert-dialog.js.map +2 -2
  23. package/dist/cjs/components/badge.js +1 -1
  24. package/dist/cjs/components/badge.js.map +2 -2
  25. package/dist/cjs/components/blockquote.js +1 -1
  26. package/dist/cjs/components/button.js +1 -1
  27. package/dist/cjs/components/button.js.map +2 -2
  28. package/dist/cjs/components/callout.js +1 -1
  29. package/dist/cjs/components/callout.js.map +2 -2
  30. package/dist/cjs/components/card.js +1 -1
  31. package/dist/cjs/components/card.js.map +2 -2
  32. package/dist/cjs/components/chatbar.d.ts +46 -44
  33. package/dist/cjs/components/chatbar.d.ts.map +1 -1
  34. package/dist/cjs/components/chatbar.js +2 -2
  35. package/dist/cjs/components/chatbar.js.map +3 -3
  36. package/dist/cjs/components/checkbox-cards.js +1 -1
  37. package/dist/cjs/components/checkbox-cards.js.map +2 -2
  38. package/dist/cjs/components/checkbox-group.js +1 -1
  39. package/dist/cjs/components/checkbox.js +1 -1
  40. package/dist/cjs/components/combobox.d.ts +28 -15
  41. package/dist/cjs/components/combobox.d.ts.map +1 -1
  42. package/dist/cjs/components/combobox.js +1 -1
  43. package/dist/cjs/components/combobox.js.map +4 -4
  44. package/dist/cjs/components/combobox.props.d.ts +48 -0
  45. package/dist/cjs/components/combobox.props.d.ts.map +1 -1
  46. package/dist/cjs/components/combobox.props.js +1 -1
  47. package/dist/cjs/components/combobox.props.js.map +4 -4
  48. package/dist/cjs/components/context-menu.d.ts +18 -5
  49. package/dist/cjs/components/context-menu.d.ts.map +1 -1
  50. package/dist/cjs/components/context-menu.js +1 -1
  51. package/dist/cjs/components/context-menu.js.map +4 -4
  52. package/dist/cjs/components/context-menu.props.d.ts +1 -1
  53. package/dist/cjs/components/context-menu.props.d.ts.map +1 -1
  54. package/dist/cjs/components/context-menu.props.js +1 -1
  55. package/dist/cjs/components/context-menu.props.js.map +3 -3
  56. package/dist/cjs/components/dialog.js +1 -1
  57. package/dist/cjs/components/dialog.js.map +2 -2
  58. package/dist/cjs/components/dropdown-menu.d.ts +11 -18
  59. package/dist/cjs/components/dropdown-menu.d.ts.map +1 -1
  60. package/dist/cjs/components/dropdown-menu.js +1 -1
  61. package/dist/cjs/components/dropdown-menu.js.map +4 -4
  62. package/dist/cjs/components/heading.js +1 -1
  63. package/dist/cjs/components/hover-card.js +1 -1
  64. package/dist/cjs/components/hover-card.js.map +2 -2
  65. package/dist/cjs/components/icon-button.js +1 -1
  66. package/dist/cjs/components/icon-button.js.map +2 -2
  67. package/dist/cjs/components/index.js +5 -5
  68. package/dist/cjs/components/index.js.map +4 -4
  69. package/dist/cjs/components/link.js +1 -1
  70. package/dist/cjs/components/popover.js +1 -1
  71. package/dist/cjs/components/popover.js.map +2 -2
  72. package/dist/cjs/components/radio-cards.js +1 -1
  73. package/dist/cjs/components/radio-cards.js.map +2 -2
  74. package/dist/cjs/components/radio.js +1 -1
  75. package/dist/cjs/components/radio.js.map +2 -2
  76. package/dist/cjs/components/schemas/base-button.schema.d.ts +111 -256
  77. package/dist/cjs/components/schemas/base-button.schema.d.ts.map +1 -1
  78. package/dist/cjs/components/schemas/button.schema.d.ts +136 -557
  79. package/dist/cjs/components/schemas/button.schema.d.ts.map +1 -1
  80. package/dist/cjs/components/schemas/icon-button.schema.d.ts +123 -240
  81. package/dist/cjs/components/schemas/icon-button.schema.d.ts.map +1 -1
  82. package/dist/cjs/components/schemas/index.js +1 -1
  83. package/dist/cjs/components/schemas/index.js.map +2 -2
  84. package/dist/cjs/components/schemas/shell.schema.d.ts +592 -851
  85. package/dist/cjs/components/schemas/shell.schema.d.ts.map +1 -1
  86. package/dist/cjs/components/schemas/shell.schema.js +1 -1
  87. package/dist/cjs/components/schemas/shell.schema.js.map +2 -2
  88. package/dist/cjs/components/schemas/toggle-button.schema.d.ts +138 -1026
  89. package/dist/cjs/components/schemas/toggle-button.schema.d.ts.map +1 -1
  90. package/dist/cjs/components/schemas/toggle-icon-button.schema.d.ts +124 -465
  91. package/dist/cjs/components/schemas/toggle-icon-button.schema.d.ts.map +1 -1
  92. package/dist/cjs/components/scroll-area.js +1 -1
  93. package/dist/cjs/components/segmented-control.js +1 -1
  94. package/dist/cjs/components/select.js +1 -1
  95. package/dist/cjs/components/select.js.map +2 -2
  96. package/dist/cjs/components/sheet.js +1 -1
  97. package/dist/cjs/components/sheet.js.map +2 -2
  98. package/dist/cjs/components/shell.js +1 -1
  99. package/dist/cjs/components/shell.js.map +3 -3
  100. package/dist/cjs/components/sidebar.js +1 -1
  101. package/dist/cjs/components/sidebar.js.map +4 -4
  102. package/dist/cjs/components/slider.js +1 -1
  103. package/dist/cjs/components/slot.d.ts +6 -2
  104. package/dist/cjs/components/slot.d.ts.map +1 -1
  105. package/dist/cjs/components/switch.js +1 -1
  106. package/dist/cjs/components/tab-nav.js +1 -1
  107. package/dist/cjs/components/text-field.js +2 -2
  108. package/dist/cjs/components/text.js +1 -1
  109. package/dist/cjs/components/theme-panel.js +3 -3
  110. package/dist/cjs/components/theme-panel.js.map +2 -2
  111. package/dist/cjs/components/theme.js +1 -1
  112. package/dist/cjs/components/theme.js.map +2 -2
  113. package/dist/cjs/components/theme.props.d.ts +1 -1
  114. package/dist/cjs/components/theme.props.js +1 -1
  115. package/dist/cjs/components/theme.props.js.map +2 -2
  116. package/dist/cjs/components/toggle-button.js +1 -1
  117. package/dist/cjs/components/toggle-button.js.map +2 -2
  118. package/dist/cjs/components/toggle-icon-button.js +1 -1
  119. package/dist/cjs/components/toggle-icon-button.js.map +2 -2
  120. package/dist/cjs/components/tooltip.js +1 -1
  121. package/dist/cjs/components/tooltip.js.map +2 -2
  122. package/dist/cjs/components/user-card.js +1 -1
  123. package/dist/cjs/components/user-card.js.map +2 -2
  124. package/dist/cjs/components/virtual-menu.d.ts.map +1 -1
  125. package/dist/cjs/components/virtual-menu.js +1 -1
  126. package/dist/cjs/components/virtual-menu.js.map +4 -4
  127. package/dist/cjs/helpers/extract-props.js +1 -1
  128. package/dist/cjs/helpers/extract-props.js.map +2 -2
  129. package/dist/cjs/helpers/get-margin-styles.js +1 -1
  130. package/dist/cjs/helpers/get-margin-styles.js.map +2 -2
  131. package/dist/cjs/hooks/use-breakpoint.d.ts +4 -1
  132. package/dist/cjs/hooks/use-breakpoint.d.ts.map +1 -1
  133. package/dist/cjs/hooks/use-breakpoint.js +1 -1
  134. package/dist/cjs/hooks/use-breakpoint.js.map +3 -3
  135. package/dist/cjs/index.js +5 -5
  136. package/dist/cjs/index.js.map +4 -4
  137. package/dist/cjs/props/index.js +1 -1
  138. package/dist/cjs/props/index.js.map +3 -3
  139. package/dist/esm/{chunk-P7HYVJSU.js → chunk-2TIADHPD.js} +2 -2
  140. package/dist/esm/chunk-37OGSEQR.js +2 -0
  141. package/dist/esm/chunk-37OGSEQR.js.map +7 -0
  142. package/dist/esm/{chunk-RMKXWYOV.js → chunk-3OBXZUFJ.js} +2 -2
  143. package/dist/esm/{chunk-S2HA3W3V.js → chunk-3R7XMRV5.js} +2 -2
  144. package/dist/esm/chunk-4SMJ7D4S.js +2 -0
  145. package/dist/esm/chunk-4SMJ7D4S.js.map +7 -0
  146. package/dist/esm/{chunk-S56YSRHP.js → chunk-56TS6OKQ.js} +2 -2
  147. package/dist/esm/{chunk-2FYJTSOP.js → chunk-5BYHEYO6.js} +2 -2
  148. package/dist/esm/{chunk-B24BDVG2.js → chunk-5MNBQROW.js} +2 -2
  149. package/dist/esm/{chunk-W7N7V5YD.js → chunk-7ESHXHHO.js} +2 -2
  150. package/dist/esm/{chunk-CRWYCLTE.js → chunk-ABSKRHVM.js} +2 -2
  151. package/dist/esm/chunk-ADBLURLJ.js +2 -0
  152. package/dist/esm/chunk-ADBLURLJ.js.map +7 -0
  153. package/dist/esm/{chunk-3MWWP4MW.js → chunk-BBJUB52V.js} +2 -2
  154. package/dist/esm/{chunk-PTLFMWCN.js → chunk-CQNPBTM5.js} +2 -2
  155. package/dist/esm/{chunk-GJSGKE7B.js → chunk-CTA5ODAO.js} +2 -2
  156. package/dist/esm/{chunk-GWXVETMF.js → chunk-DCAZPMOB.js} +2 -2
  157. package/dist/esm/{chunk-PLWXTEVE.js → chunk-DMJJM5IS.js} +2 -2
  158. package/dist/esm/{chunk-A2RZGGMY.js → chunk-DQAUX5ZL.js} +2 -2
  159. package/dist/esm/{chunk-6XGZPIUT.js → chunk-DX5JJYMZ.js} +2 -2
  160. package/dist/esm/{chunk-6XGZPIUT.js.map → chunk-DX5JJYMZ.js.map} +2 -2
  161. package/dist/esm/{chunk-YUP6EICB.js → chunk-DXL6V2A5.js} +2 -2
  162. package/dist/esm/{chunk-XMRIRFM7.js → chunk-FG7XM7KJ.js} +2 -2
  163. package/dist/esm/{chunk-IKSN4RNB.js → chunk-FIB3XATL.js} +3 -3
  164. package/dist/esm/chunk-FMBECDL4.js +2 -0
  165. package/dist/esm/{chunk-4XJY6FIK.js.map → chunk-FMBECDL4.js.map} +3 -3
  166. package/dist/esm/{chunk-374ZYJUP.js → chunk-HBR42MIX.js} +2 -2
  167. package/dist/esm/{chunk-2UPAYDKR.js → chunk-HIKVMKYQ.js} +2 -2
  168. package/dist/esm/{chunk-VX6ARMWP.js → chunk-HSAG7C53.js} +2 -2
  169. package/dist/esm/{chunk-OSRJRBKP.js → chunk-HW324FK3.js} +2 -2
  170. package/dist/esm/{chunk-DNHUQLXO.js → chunk-JBO5YOUM.js} +2 -2
  171. package/dist/esm/{chunk-HF5WY5YE.js → chunk-JGLAE5G7.js} +2 -2
  172. package/dist/esm/{chunk-4YFJWXL2.js → chunk-JJKQ7CGI.js} +2 -2
  173. package/dist/esm/chunk-JSW6J6RS.js +2 -0
  174. package/dist/esm/chunk-JSW6J6RS.js.map +7 -0
  175. package/dist/esm/chunk-KRDP5SQ7.js +2 -0
  176. package/dist/esm/chunk-KRDP5SQ7.js.map +7 -0
  177. package/dist/esm/{chunk-CWRNW44F.js → chunk-L6PVH3RM.js} +2 -2
  178. package/dist/esm/{chunk-MK2R6DAU.js → chunk-LAOMR7SE.js} +2 -2
  179. package/dist/esm/{chunk-FHOCUZDR.js → chunk-NIQT45PJ.js} +2 -2
  180. package/dist/esm/chunk-NKEJTTIL.js +2 -0
  181. package/dist/esm/chunk-NKEJTTIL.js.map +7 -0
  182. package/dist/esm/chunk-NVDXXGVW.js +2 -0
  183. package/dist/esm/chunk-NVDXXGVW.js.map +7 -0
  184. package/dist/esm/chunk-OS3FCYZY.js +2 -0
  185. package/dist/esm/{chunk-7QDTSBJF.js → chunk-OYF6YXH7.js} +2 -2
  186. package/dist/esm/{chunk-WFMYNZXZ.js → chunk-P5OSU4NX.js} +2 -2
  187. package/dist/esm/{chunk-QEOAXJLN.js → chunk-PTS6QYQW.js} +2 -2
  188. package/dist/esm/{chunk-CVWCKOHO.js → chunk-RLPKKCBL.js} +2 -2
  189. package/dist/esm/{chunk-LKAK5GXF.js → chunk-SPIU5JYC.js} +2 -2
  190. package/dist/esm/chunk-SZTZ2XLI.js +2 -0
  191. package/dist/esm/chunk-SZTZ2XLI.js.map +7 -0
  192. package/dist/esm/chunk-T2FZN7G3.js +2 -0
  193. package/dist/esm/chunk-T2FZN7G3.js.map +7 -0
  194. package/dist/esm/{chunk-4AQTUBVP.js → chunk-T4UHUQFJ.js} +2 -2
  195. package/dist/esm/{chunk-K6QZMNCD.js → chunk-TS623UPD.js} +2 -2
  196. package/dist/esm/{chunk-YV2KYHGR.js → chunk-UBOOUDS4.js} +2 -2
  197. package/dist/esm/{chunk-D6B7IMZU.js → chunk-UFI53QUC.js} +2 -2
  198. package/dist/esm/{chunk-AU3D6F42.js → chunk-UIRSGVJG.js} +2 -2
  199. package/dist/esm/{chunk-FXUUI6Z2.js → chunk-UUUZXUIP.js} +2 -2
  200. package/dist/esm/{chunk-25WZA7ZU.js → chunk-VA4CTVQL.js} +2 -2
  201. package/dist/esm/{chunk-TOLLPRMM.js → chunk-VNPNEJI6.js} +2 -2
  202. package/dist/esm/{chunk-6IGTK4JE.js → chunk-YMUGKYGW.js} +2 -2
  203. package/dist/esm/chunk-ZWYYPFZW.js +2 -0
  204. package/dist/esm/chunk-ZWYYPFZW.js.map +7 -0
  205. package/dist/esm/{chunk-M24UXMOQ.js → chunk-ZXZOH63L.js} +2 -2
  206. package/dist/esm/components/_internal/base-button.js +1 -1
  207. package/dist/esm/components/_internal/base-button.props.js +1 -1
  208. package/dist/esm/components/_internal/base-checkbox.props.js +1 -1
  209. package/dist/esm/components/_internal/base-menu.props.js +1 -1
  210. package/dist/esm/components/_internal/base-menu.utils.d.ts +48 -0
  211. package/dist/esm/components/_internal/base-menu.utils.d.ts.map +1 -0
  212. package/dist/esm/components/_internal/base-menu.utils.js +2 -0
  213. package/dist/esm/components/_internal/base-menu.utils.js.map +7 -0
  214. package/dist/esm/components/_internal/base-radio.props.js +1 -1
  215. package/dist/esm/components/_internal/base-tab-list.props.js +1 -1
  216. package/dist/esm/components/_internal/dropdown-menu-drill-down.d.ts +77 -59
  217. package/dist/esm/components/_internal/dropdown-menu-drill-down.d.ts.map +1 -1
  218. package/dist/esm/components/_internal/dropdown-menu-drill-down.js +1 -1
  219. package/dist/esm/components/_internal/menu-context.d.ts +7 -3
  220. package/dist/esm/components/_internal/menu-context.d.ts.map +1 -1
  221. package/dist/esm/components/_internal/menu-context.js +1 -1
  222. package/dist/esm/components/_internal/shell-bottom.js +1 -1
  223. package/dist/esm/components/_internal/shell-handles.js +1 -1
  224. package/dist/esm/components/_internal/shell-inspector.js +1 -1
  225. package/dist/esm/components/_internal/shell-prop-helpers.js +1 -1
  226. package/dist/esm/components/_internal/shell-resize.js +1 -1
  227. package/dist/esm/components/_internal/shell-sidebar.js +1 -1
  228. package/dist/esm/components/accessible-icon.js +1 -1
  229. package/dist/esm/components/accordion.js +1 -1
  230. package/dist/esm/components/accordion.props.js +1 -1
  231. package/dist/esm/components/alert-dialog.js +1 -1
  232. package/dist/esm/components/alert-dialog.props.js +1 -1
  233. package/dist/esm/components/aspect-ratio.js +1 -1
  234. package/dist/esm/components/avatar.js +1 -1
  235. package/dist/esm/components/avatar.props.js +1 -1
  236. package/dist/esm/components/badge.js +1 -1
  237. package/dist/esm/components/badge.props.js +1 -1
  238. package/dist/esm/components/blockquote.js +1 -1
  239. package/dist/esm/components/blockquote.props.js +1 -1
  240. package/dist/esm/components/box.js +1 -1
  241. package/dist/esm/components/box.props.js +1 -1
  242. package/dist/esm/components/button.js +1 -1
  243. package/dist/esm/components/button.props.js +1 -1
  244. package/dist/esm/components/callout.js +1 -1
  245. package/dist/esm/components/callout.props.js +1 -1
  246. package/dist/esm/components/card.js +1 -1
  247. package/dist/esm/components/card.props.js +1 -1
  248. package/dist/esm/components/chatbar.d.ts +46 -44
  249. package/dist/esm/components/chatbar.d.ts.map +1 -1
  250. package/dist/esm/components/chatbar.js +1 -1
  251. package/dist/esm/components/checkbox-cards.js +1 -1
  252. package/dist/esm/components/checkbox-cards.props.js +1 -1
  253. package/dist/esm/components/checkbox-group.js +1 -1
  254. package/dist/esm/components/checkbox-group.primitive.js +1 -1
  255. package/dist/esm/components/checkbox-group.props.js +1 -1
  256. package/dist/esm/components/checkbox.js +1 -1
  257. package/dist/esm/components/checkbox.props.js +1 -1
  258. package/dist/esm/components/code.js +1 -1
  259. package/dist/esm/components/code.props.js +1 -1
  260. package/dist/esm/components/combobox.d.ts +28 -15
  261. package/dist/esm/components/combobox.d.ts.map +1 -1
  262. package/dist/esm/components/combobox.js +1 -1
  263. package/dist/esm/components/combobox.props.d.ts +48 -0
  264. package/dist/esm/components/combobox.props.d.ts.map +1 -1
  265. package/dist/esm/components/combobox.props.js +1 -1
  266. package/dist/esm/components/container.js +1 -1
  267. package/dist/esm/components/container.props.js +1 -1
  268. package/dist/esm/components/context-menu.d.ts +18 -5
  269. package/dist/esm/components/context-menu.d.ts.map +1 -1
  270. package/dist/esm/components/context-menu.js +1 -1
  271. package/dist/esm/components/context-menu.props.d.ts +1 -1
  272. package/dist/esm/components/context-menu.props.d.ts.map +1 -1
  273. package/dist/esm/components/context-menu.props.js +1 -1
  274. package/dist/esm/components/data-list.js +1 -1
  275. package/dist/esm/components/data-list.props.js +1 -1
  276. package/dist/esm/components/dialog.js +1 -1
  277. package/dist/esm/components/dialog.props.js +1 -1
  278. package/dist/esm/components/dropdown-menu.d.ts +11 -18
  279. package/dist/esm/components/dropdown-menu.d.ts.map +1 -1
  280. package/dist/esm/components/dropdown-menu.js +1 -1
  281. package/dist/esm/components/dropdown-menu.props.js +1 -1
  282. package/dist/esm/components/em.js +1 -1
  283. package/dist/esm/components/em.props.js +1 -1
  284. package/dist/esm/components/flex.js +1 -1
  285. package/dist/esm/components/flex.props.js +1 -1
  286. package/dist/esm/components/grid.js +1 -1
  287. package/dist/esm/components/grid.props.js +1 -1
  288. package/dist/esm/components/heading.js +1 -1
  289. package/dist/esm/components/heading.props.js +1 -1
  290. package/dist/esm/components/hover-card.js +1 -1
  291. package/dist/esm/components/hover-card.props.js +1 -1
  292. package/dist/esm/components/icon-button.js +1 -1
  293. package/dist/esm/components/icon-button.props.js +1 -1
  294. package/dist/esm/components/icons.js +1 -1
  295. package/dist/esm/components/image.js +1 -1
  296. package/dist/esm/components/image.props.js +1 -1
  297. package/dist/esm/components/index.js +1 -1
  298. package/dist/esm/components/inset.js +1 -1
  299. package/dist/esm/components/inset.props.js +1 -1
  300. package/dist/esm/components/kbd.js +1 -1
  301. package/dist/esm/components/kbd.props.js +1 -1
  302. package/dist/esm/components/link.js +1 -1
  303. package/dist/esm/components/link.props.js +1 -1
  304. package/dist/esm/components/navbar.js +1 -1
  305. package/dist/esm/components/navbar.props.js +1 -1
  306. package/dist/esm/components/popover.js +1 -1
  307. package/dist/esm/components/popover.props.js +1 -1
  308. package/dist/esm/components/portal.js +1 -1
  309. package/dist/esm/components/progress.js +1 -1
  310. package/dist/esm/components/progress.props.js +1 -1
  311. package/dist/esm/components/quote.js +1 -1
  312. package/dist/esm/components/quote.props.js +1 -1
  313. package/dist/esm/components/radio-cards.js +1 -1
  314. package/dist/esm/components/radio-cards.props.js +1 -1
  315. package/dist/esm/components/radio-group.js +1 -1
  316. package/dist/esm/components/radio-group.props.js +1 -1
  317. package/dist/esm/components/radio.js +1 -1
  318. package/dist/esm/components/radio.props.js +1 -1
  319. package/dist/esm/components/reset.js +1 -1
  320. package/dist/esm/components/schemas/base-button.schema.d.ts +111 -256
  321. package/dist/esm/components/schemas/base-button.schema.d.ts.map +1 -1
  322. package/dist/esm/components/schemas/base-button.schema.js +1 -1
  323. package/dist/esm/components/schemas/button.schema.d.ts +136 -557
  324. package/dist/esm/components/schemas/button.schema.d.ts.map +1 -1
  325. package/dist/esm/components/schemas/button.schema.js +1 -1
  326. package/dist/esm/components/schemas/icon-button.schema.d.ts +123 -240
  327. package/dist/esm/components/schemas/icon-button.schema.d.ts.map +1 -1
  328. package/dist/esm/components/schemas/icon-button.schema.js +1 -1
  329. package/dist/esm/components/schemas/index.js +1 -1
  330. package/dist/esm/components/schemas/shell.schema.d.ts +592 -851
  331. package/dist/esm/components/schemas/shell.schema.d.ts.map +1 -1
  332. package/dist/esm/components/schemas/shell.schema.js +1 -1
  333. package/dist/esm/components/schemas/toggle-button.schema.d.ts +138 -1026
  334. package/dist/esm/components/schemas/toggle-button.schema.d.ts.map +1 -1
  335. package/dist/esm/components/schemas/toggle-button.schema.js +1 -1
  336. package/dist/esm/components/schemas/toggle-icon-button.schema.d.ts +124 -465
  337. package/dist/esm/components/schemas/toggle-icon-button.schema.d.ts.map +1 -1
  338. package/dist/esm/components/schemas/toggle-icon-button.schema.js +1 -1
  339. package/dist/esm/components/scroll-area.js +1 -1
  340. package/dist/esm/components/scroll-area.props.js +1 -1
  341. package/dist/esm/components/section.js +1 -1
  342. package/dist/esm/components/section.props.js +1 -1
  343. package/dist/esm/components/segmented-control.js +1 -1
  344. package/dist/esm/components/segmented-control.props.js +1 -1
  345. package/dist/esm/components/select.js +1 -1
  346. package/dist/esm/components/select.props.js +1 -1
  347. package/dist/esm/components/separator.js +1 -1
  348. package/dist/esm/components/separator.props.js +1 -1
  349. package/dist/esm/components/sheet.js +1 -1
  350. package/dist/esm/components/shell.context.js +1 -1
  351. package/dist/esm/components/shell.hooks.js +1 -1
  352. package/dist/esm/components/shell.js +1 -1
  353. package/dist/esm/components/shell.types.js +1 -1
  354. package/dist/esm/components/sidebar.js +1 -1
  355. package/dist/esm/components/sidebar.props.js +1 -1
  356. package/dist/esm/components/skeleton.js +1 -1
  357. package/dist/esm/components/skeleton.props.js +1 -1
  358. package/dist/esm/components/slider.js +1 -1
  359. package/dist/esm/components/slider.props.js +1 -1
  360. package/dist/esm/components/slot.d.ts +6 -2
  361. package/dist/esm/components/slot.d.ts.map +1 -1
  362. package/dist/esm/components/slot.js +1 -1
  363. package/dist/esm/components/spinner.js +1 -1
  364. package/dist/esm/components/spinner.props.js +1 -1
  365. package/dist/esm/components/strong.js +1 -1
  366. package/dist/esm/components/strong.props.js +1 -1
  367. package/dist/esm/components/switch.js +1 -1
  368. package/dist/esm/components/switch.props.js +1 -1
  369. package/dist/esm/components/tab-nav.js +1 -1
  370. package/dist/esm/components/tab-nav.props.js +1 -1
  371. package/dist/esm/components/table.js +1 -1
  372. package/dist/esm/components/table.props.js +1 -1
  373. package/dist/esm/components/tabs.js +1 -1
  374. package/dist/esm/components/tabs.props.js +1 -1
  375. package/dist/esm/components/text-area.js +1 -1
  376. package/dist/esm/components/text-area.props.js +1 -1
  377. package/dist/esm/components/text-field.js +1 -1
  378. package/dist/esm/components/text-field.props.js +1 -1
  379. package/dist/esm/components/text.js +1 -1
  380. package/dist/esm/components/text.props.js +1 -1
  381. package/dist/esm/components/theme-panel.js +1 -1
  382. package/dist/esm/components/theme.js +1 -1
  383. package/dist/esm/components/theme.props.d.ts +1 -1
  384. package/dist/esm/components/theme.props.js +1 -1
  385. package/dist/esm/components/toggle-button.js +1 -1
  386. package/dist/esm/components/toggle-icon-button.js +1 -1
  387. package/dist/esm/components/toolbar.js +1 -1
  388. package/dist/esm/components/tooltip.js +1 -1
  389. package/dist/esm/components/tooltip.props.js +1 -1
  390. package/dist/esm/components/user-card.js +1 -1
  391. package/dist/esm/components/user-card.props.js +1 -1
  392. package/dist/esm/components/virtual-menu.d.ts.map +1 -1
  393. package/dist/esm/components/virtual-menu.js +1 -1
  394. package/dist/esm/components/visually-hidden.js +1 -1
  395. package/dist/esm/helpers/compile-prop-defs.js +1 -1
  396. package/dist/esm/helpers/extract-margin-props.js +1 -1
  397. package/dist/esm/helpers/extract-props.js +1 -1
  398. package/dist/esm/helpers/font-config.js +1 -1
  399. package/dist/esm/helpers/get-margin-styles.js +1 -1
  400. package/dist/esm/helpers/get-matching-gray-color.js +1 -1
  401. package/dist/esm/helpers/get-responsive-styles.js +1 -1
  402. package/dist/esm/helpers/get-subtree.js +1 -1
  403. package/dist/esm/helpers/has-own-property.js +1 -1
  404. package/dist/esm/helpers/index.js +1 -1
  405. package/dist/esm/helpers/inert.js +1 -1
  406. package/dist/esm/helpers/is-responsive-object.js +1 -1
  407. package/dist/esm/helpers/map-prop-values.js +1 -1
  408. package/dist/esm/helpers/merge-styles.js +1 -1
  409. package/dist/esm/helpers/normalize-to-px.js +1 -1
  410. package/dist/esm/helpers/require-react-element.js +1 -1
  411. package/dist/esm/helpers/use-deprecation-warning.js +1 -1
  412. package/dist/esm/hooks/index.js +1 -1
  413. package/dist/esm/hooks/use-body-pointer-events-cleanup.js +1 -1
  414. package/dist/esm/hooks/use-breakpoint.d.ts +4 -1
  415. package/dist/esm/hooks/use-breakpoint.d.ts.map +1 -1
  416. package/dist/esm/hooks/use-breakpoint.js +1 -1
  417. package/dist/esm/hooks/use-live-announcer.js +1 -1
  418. package/dist/esm/hooks/use-prop-synced-state.js +1 -1
  419. package/dist/esm/hooks/use-toggle-state.js +1 -1
  420. package/dist/esm/hooks/use-tooltip-wrapper.js +1 -1
  421. package/dist/esm/index.js +1 -1
  422. package/dist/esm/props/as-child.prop.js +1 -1
  423. package/dist/esm/props/color.prop.js +1 -1
  424. package/dist/esm/props/font-family.prop.js +1 -1
  425. package/dist/esm/props/gap.props.js +1 -1
  426. package/dist/esm/props/height.props.js +1 -1
  427. package/dist/esm/props/high-contrast.prop.js +1 -1
  428. package/dist/esm/props/index.js +1 -1
  429. package/dist/esm/props/layout.props.js +1 -1
  430. package/dist/esm/props/leading-trim.prop.js +1 -1
  431. package/dist/esm/props/margin.props.js +1 -1
  432. package/dist/esm/props/padding.props.js +1 -1
  433. package/dist/esm/props/prop-def.js +1 -1
  434. package/dist/esm/props/radius.prop.js +1 -1
  435. package/dist/esm/props/shadow.props.js +1 -1
  436. package/dist/esm/props/text-align.prop.js +1 -1
  437. package/dist/esm/props/text-wrap.prop.js +1 -1
  438. package/dist/esm/props/truncate.prop.js +1 -1
  439. package/dist/esm/props/weight.prop.js +1 -1
  440. package/dist/esm/props/width.props.js +1 -1
  441. package/fonts.css +2 -0
  442. package/package.json +36 -36
  443. package/schemas/base-button.json +66 -60
  444. package/schemas/button.json +80 -74
  445. package/schemas/icon-button.json +78 -72
  446. package/schemas/index.json +390 -360
  447. package/schemas/toggle-button.json +84 -78
  448. package/schemas/toggle-icon-button.json +82 -76
  449. package/src/components/_internal/base-card.css +5 -5
  450. package/src/components/_internal/base-checkbox.css +17 -3
  451. package/src/components/_internal/base-dialog.css +4 -4
  452. package/src/components/_internal/base-menu.css +68 -143
  453. package/src/components/_internal/base-menu.utils.tsx +144 -0
  454. package/src/components/_internal/dropdown-menu-drill-down.tsx +281 -183
  455. package/src/components/_internal/menu-context.tsx +24 -15
  456. package/src/components/accordion.css +5 -5
  457. package/src/components/callout.css +5 -5
  458. package/src/components/chatbar.css +100 -32
  459. package/src/components/chatbar.tsx +739 -735
  460. package/src/components/checkbox-cards.css +30 -0
  461. package/src/components/combobox.css +62 -115
  462. package/src/components/combobox.props.tsx +14 -0
  463. package/src/components/combobox.tsx +440 -212
  464. package/src/components/context-menu.props.tsx +4 -1
  465. package/src/components/context-menu.tsx +108 -62
  466. package/src/components/dropdown-menu.css +41 -121
  467. package/src/components/dropdown-menu.tsx +430 -275
  468. package/src/components/hover-card.css +3 -3
  469. package/src/components/popover.css +4 -4
  470. package/src/components/radio-cards.css +30 -0
  471. package/src/components/schemas/shell.schema.ts +11 -18
  472. package/src/components/scroll-area.css +2 -3
  473. package/src/components/select.css +2 -2
  474. package/src/components/shell.css +1 -1
  475. package/src/components/table.css +5 -5
  476. package/src/components/theme.props.tsx +1 -1
  477. package/src/components/tooltip.css +1 -1
  478. package/src/components/virtual-menu.tsx +12 -8
  479. package/src/hooks/use-breakpoint.ts +19 -3
  480. package/src/styles/fonts.css +6 -1
  481. package/src/styles/tokens/font-family.css +13 -5
  482. package/src/styles/tokens/radius.css +47 -4
  483. package/styles.css +801 -784
  484. package/tokens/base.css +19 -2
  485. package/tokens.css +19 -2
  486. package/dist/esm/chunk-3XT5P2HO.js +0 -2
  487. package/dist/esm/chunk-4XJY6FIK.js +0 -2
  488. package/dist/esm/chunk-4Z33XHOK.js +0 -2
  489. package/dist/esm/chunk-4Z33XHOK.js.map +0 -7
  490. package/dist/esm/chunk-E2Z5IA2P.js +0 -2
  491. package/dist/esm/chunk-E2Z5IA2P.js.map +0 -7
  492. package/dist/esm/chunk-HVWEPVBH.js +0 -2
  493. package/dist/esm/chunk-HVWEPVBH.js.map +0 -7
  494. package/dist/esm/chunk-KKBP2CRH.js +0 -2
  495. package/dist/esm/chunk-KKBP2CRH.js.map +0 -7
  496. package/dist/esm/chunk-MOR3NSXQ.js +0 -2
  497. package/dist/esm/chunk-MOR3NSXQ.js.map +0 -7
  498. package/dist/esm/chunk-QCNUTT4R.js +0 -2
  499. package/dist/esm/chunk-QCNUTT4R.js.map +0 -7
  500. package/dist/esm/chunk-UGD7Z3L4.js +0 -2
  501. package/dist/esm/chunk-UGD7Z3L4.js.map +0 -7
  502. package/dist/esm/chunk-YEB3H7QN.js +0 -2
  503. package/dist/esm/chunk-YEB3H7QN.js.map +0 -7
  504. package/dist/esm/chunk-ZY6SQWHT.js +0 -2
  505. package/dist/esm/chunk-ZY6SQWHT.js.map +0 -7
  506. /package/dist/esm/{chunk-P7HYVJSU.js.map → chunk-2TIADHPD.js.map} +0 -0
  507. /package/dist/esm/{chunk-RMKXWYOV.js.map → chunk-3OBXZUFJ.js.map} +0 -0
  508. /package/dist/esm/{chunk-S2HA3W3V.js.map → chunk-3R7XMRV5.js.map} +0 -0
  509. /package/dist/esm/{chunk-S56YSRHP.js.map → chunk-56TS6OKQ.js.map} +0 -0
  510. /package/dist/esm/{chunk-2FYJTSOP.js.map → chunk-5BYHEYO6.js.map} +0 -0
  511. /package/dist/esm/{chunk-B24BDVG2.js.map → chunk-5MNBQROW.js.map} +0 -0
  512. /package/dist/esm/{chunk-W7N7V5YD.js.map → chunk-7ESHXHHO.js.map} +0 -0
  513. /package/dist/esm/{chunk-CRWYCLTE.js.map → chunk-ABSKRHVM.js.map} +0 -0
  514. /package/dist/esm/{chunk-3MWWP4MW.js.map → chunk-BBJUB52V.js.map} +0 -0
  515. /package/dist/esm/{chunk-PTLFMWCN.js.map → chunk-CQNPBTM5.js.map} +0 -0
  516. /package/dist/esm/{chunk-GJSGKE7B.js.map → chunk-CTA5ODAO.js.map} +0 -0
  517. /package/dist/esm/{chunk-GWXVETMF.js.map → chunk-DCAZPMOB.js.map} +0 -0
  518. /package/dist/esm/{chunk-PLWXTEVE.js.map → chunk-DMJJM5IS.js.map} +0 -0
  519. /package/dist/esm/{chunk-A2RZGGMY.js.map → chunk-DQAUX5ZL.js.map} +0 -0
  520. /package/dist/esm/{chunk-YUP6EICB.js.map → chunk-DXL6V2A5.js.map} +0 -0
  521. /package/dist/esm/{chunk-XMRIRFM7.js.map → chunk-FG7XM7KJ.js.map} +0 -0
  522. /package/dist/esm/{chunk-IKSN4RNB.js.map → chunk-FIB3XATL.js.map} +0 -0
  523. /package/dist/esm/{chunk-374ZYJUP.js.map → chunk-HBR42MIX.js.map} +0 -0
  524. /package/dist/esm/{chunk-2UPAYDKR.js.map → chunk-HIKVMKYQ.js.map} +0 -0
  525. /package/dist/esm/{chunk-VX6ARMWP.js.map → chunk-HSAG7C53.js.map} +0 -0
  526. /package/dist/esm/{chunk-OSRJRBKP.js.map → chunk-HW324FK3.js.map} +0 -0
  527. /package/dist/esm/{chunk-DNHUQLXO.js.map → chunk-JBO5YOUM.js.map} +0 -0
  528. /package/dist/esm/{chunk-HF5WY5YE.js.map → chunk-JGLAE5G7.js.map} +0 -0
  529. /package/dist/esm/{chunk-4YFJWXL2.js.map → chunk-JJKQ7CGI.js.map} +0 -0
  530. /package/dist/esm/{chunk-CWRNW44F.js.map → chunk-L6PVH3RM.js.map} +0 -0
  531. /package/dist/esm/{chunk-MK2R6DAU.js.map → chunk-LAOMR7SE.js.map} +0 -0
  532. /package/dist/esm/{chunk-FHOCUZDR.js.map → chunk-NIQT45PJ.js.map} +0 -0
  533. /package/dist/esm/{chunk-3XT5P2HO.js.map → chunk-OS3FCYZY.js.map} +0 -0
  534. /package/dist/esm/{chunk-7QDTSBJF.js.map → chunk-OYF6YXH7.js.map} +0 -0
  535. /package/dist/esm/{chunk-WFMYNZXZ.js.map → chunk-P5OSU4NX.js.map} +0 -0
  536. /package/dist/esm/{chunk-QEOAXJLN.js.map → chunk-PTS6QYQW.js.map} +0 -0
  537. /package/dist/esm/{chunk-CVWCKOHO.js.map → chunk-RLPKKCBL.js.map} +0 -0
  538. /package/dist/esm/{chunk-LKAK5GXF.js.map → chunk-SPIU5JYC.js.map} +0 -0
  539. /package/dist/esm/{chunk-4AQTUBVP.js.map → chunk-T4UHUQFJ.js.map} +0 -0
  540. /package/dist/esm/{chunk-K6QZMNCD.js.map → chunk-TS623UPD.js.map} +0 -0
  541. /package/dist/esm/{chunk-YV2KYHGR.js.map → chunk-UBOOUDS4.js.map} +0 -0
  542. /package/dist/esm/{chunk-D6B7IMZU.js.map → chunk-UFI53QUC.js.map} +0 -0
  543. /package/dist/esm/{chunk-AU3D6F42.js.map → chunk-UIRSGVJG.js.map} +0 -0
  544. /package/dist/esm/{chunk-FXUUI6Z2.js.map → chunk-UUUZXUIP.js.map} +0 -0
  545. /package/dist/esm/{chunk-25WZA7ZU.js.map → chunk-VA4CTVQL.js.map} +0 -0
  546. /package/dist/esm/{chunk-TOLLPRMM.js.map → chunk-VNPNEJI6.js.map} +0 -0
  547. /package/dist/esm/{chunk-6IGTK4JE.js.map → chunk-YMUGKYGW.js.map} +0 -0
  548. /package/dist/esm/{chunk-M24UXMOQ.js.map → chunk-ZXZOH63L.js.map} +0 -0
@@ -1,20 +1,58 @@
1
1
  import * as React from 'react';
2
2
  import classNames from 'classnames';
3
- import { LazyMotion, domAnimation, m } from 'motion/react';
3
+ import { LazyMotion, MotionConfig, domAnimation, m } from 'motion/react';
4
+ import { useComposedRefs } from 'radix-ui/internal';
5
+ import { useDropzone } from 'react-dropzone';
4
6
 
5
7
  import { IconButton, type IconButtonProps } from './icon-button.js';
6
8
  import { CloseIcon, FileTextIcon } from './icons.js';
7
9
  import { Flex } from './flex.js';
8
-
9
10
  import { ScrollArea } from './scroll-area.js';
10
11
  import { Slot } from './slot.js';
11
12
  import { Text } from './text.js';
12
- import { useDropzone } from 'react-dropzone';
13
+ import { VisuallyHidden } from './visually-hidden.js';
13
14
  import type { ComponentPropsWithout, RemovedProps } from '../helpers/component-props.js';
15
+ import type { accentColors } from '../props/color.prop.js';
16
+ import type { radii } from '../props/radius.prop.js';
14
17
 
15
18
  // Avoid SSR warnings by using an isomorphic layout effect
16
19
  const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;
17
20
 
21
+ /**
22
+ * Runs `measure` with the placeholder taken out of layout, so a height read
23
+ * reflects the value alone.
24
+ *
25
+ * A textarea with an empty value lays its placeholder out as real text, so
26
+ * `scrollHeight` reports the placeholder wrapped at whatever width the field
27
+ * has at that instant. That sizes an empty bar to the suggestion showing in it
28
+ * rather than to `minLines`, and when the read lands before the field has its
29
+ * final width -- a panel still animating open, a flex parent whose basis is 0
30
+ * -- it wraps that text at a fraction of the real width and opens the bar at
31
+ * `maxLines`. The first keystroke takes the placeholder out of layout and the
32
+ * height snaps back, which is what made the bug look intermittent rather than
33
+ * simply wrong.
34
+ *
35
+ * The reset to 'auto' and the `scrollHeight` read sit in different functions
36
+ * here, so the whole measurement window is wrapped rather than a single read.
37
+ * Costs one extra style recalc on a path that is already forcing a reflow.
38
+ */
39
+ function withoutPlaceholder(el: HTMLTextAreaElement, measure: () => void): void {
40
+ const { placeholder } = el;
41
+ if (!placeholder) {
42
+ measure();
43
+ return;
44
+ }
45
+ el.placeholder = '';
46
+ try {
47
+ measure();
48
+ } finally {
49
+ el.placeholder = placeholder;
50
+ }
51
+ }
52
+
53
+ // Shared layout spring; hoisted so every motion node receives the same object
54
+ const LAYOUT_TRANSITION = { layout: { type: 'spring', visualDuration: 0.15, bounce: 0.1 } } as const;
55
+
18
56
  type ExpandOn = 'none' | 'focus' | 'overflow' | 'both';
19
57
  type SendMode = 'always' | 'whenDirty' | 'never';
20
58
 
@@ -24,7 +62,9 @@ type AttachmentStatus = 'idle' | 'uploading' | 'error' | 'done';
24
62
  /**
25
63
  * Attachment data model used by Chatbar.
26
64
  * - `file` exposes the original File object for uploads and processing.
27
- * - `url` is an object URL used for image previews and is revoked on removal.
65
+ * - `url` is an object URL used for image previews. Chatbar revokes it when the
66
+ * attachment is removed; once an attachment is handed to `onSubmit`, the
67
+ * consumer owns the URL and is responsible for revoking it.
28
68
  */
29
69
  interface ChatbarAttachment {
30
70
  id: string;
@@ -39,14 +79,27 @@ interface ChatbarAttachment {
39
79
  meta?: Record<string, unknown>;
40
80
  }
41
81
 
82
+ type ChatbarRejectReason = 'type' | 'size' | 'count';
83
+ interface ChatbarAttachmentRejection {
84
+ file: File;
85
+ reason: ChatbarRejectReason;
86
+ }
87
+
88
+ interface ChatbarSubmitPayload {
89
+ value: string;
90
+ attachments: ChatbarAttachment[];
91
+ }
92
+
93
+ interface FilePickerOptions {
94
+ accept?: string | string[];
95
+ multiple?: boolean;
96
+ }
97
+
42
98
  interface ChatbarContextValue {
43
99
  open: boolean;
44
100
  setOpen(next: boolean): void;
45
- isOpenControlled: boolean;
46
101
 
47
- value: string;
48
102
  setValue(next: string): void;
49
- isValueControlled: boolean;
50
103
 
51
104
  size: '1' | '2' | '3';
52
105
  expandOn: ExpandOn;
@@ -55,48 +108,64 @@ interface ChatbarContextValue {
55
108
  sendMode: SendMode;
56
109
  disabled?: boolean;
57
110
  readOnly?: boolean;
58
-
59
- // Submit returns both message and attachments
60
- onSubmit?: (payload: { value: string; attachments: ChatbarAttachment[] }) => void;
61
-
62
- rootRef: React.RefObject<HTMLDivElement | null>;
63
- textareaRef: React.RefObject<HTMLTextAreaElement | null>;
64
-
65
- // Attachments state
66
- attachments: ChatbarAttachment[];
67
- setAttachments(next: ChatbarAttachment[]): void;
68
- isAttachmentsControlled: boolean;
69
-
70
- // Config
71
- accept?: string | string[];
72
- multiple: boolean;
73
- maxAttachments?: number;
74
- maxFileSize?: number;
75
111
  paste: boolean;
76
- pasteAccept?: string | string[];
77
112
  clearOnSubmit: boolean;
113
+ multiple: boolean;
78
114
 
79
- // Dropzone
80
- dropzone: boolean;
81
-
82
- // Events
83
- onAttachmentReject?: (rejections: { file: File; reason: 'type' | 'size' | 'count' }[]) => void;
84
-
85
- // Helpers
86
- appendFiles(files: File[]): void;
87
- appendFilesFromPaste(files: File[]): void;
115
+ textareaRef: React.RefObject<HTMLTextAreaElement | null>;
88
116
 
89
- // Guards
90
- fileDialogOpenRef: React.MutableRefObject<boolean>;
117
+ attachments: ChatbarAttachment[];
118
+ removeAttachment(id: string): void;
119
+
120
+ /** Validates and submits; returns false when there was nothing to submit */
121
+ submit(clear: boolean): boolean;
122
+ appendFiles(files: File[]): number;
123
+ appendFilesFromPaste(files: File[]): number;
124
+ openFilePicker(options?: FilePickerOptions): void;
125
+ /** Guards blur-collapse between pointerdown on a picker trigger and its click */
126
+ beginFilePickerPress(): void;
91
127
  }
92
128
 
93
129
  const ChatbarContext = React.createContext<ChatbarContextValue | null>(null);
130
+ // Value lives in its own context so keystrokes only re-render parts that read it
131
+ const ChatbarValueContext = React.createContext<string>('');
132
+
94
133
  const useChatbarContext = () => {
95
134
  const ctx = React.useContext(ChatbarContext);
96
135
  if (!ctx) throw new Error('Chatbar context not found. Wrap parts in <Chatbar.Root>.');
97
136
  return ctx;
98
137
  };
99
138
 
139
+ const toArray = (val: string | string[] | undefined) =>
140
+ Array.isArray(val) ? val : typeof val === 'string' ? val.split(',').map((s) => s.trim()).filter(Boolean) : [];
141
+
142
+ const matchesAccept = (file: File, patterns: string[]) => {
143
+ if (patterns.length === 0) return true;
144
+ const mime = file.type.toLowerCase();
145
+ const name = file.name.toLowerCase();
146
+ for (const patRaw of patterns) {
147
+ const pat = patRaw.toLowerCase();
148
+ if (pat.includes('/')) {
149
+ const [type, subtype] = pat.split('/');
150
+ const [fmType, fmSubtype] = mime.split('/');
151
+ if (type === '*' || (type === fmType && (subtype === '*' || subtype === fmSubtype))) return true;
152
+ } else if (pat.startsWith('.')) {
153
+ if (name.endsWith(pat)) return true;
154
+ }
155
+ }
156
+ return false;
157
+ };
158
+
159
+ const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|svg|avif)$/i;
160
+ const isImageLike = (name: string, type: string) => (type || '').toLowerCase().startsWith('image/') || IMAGE_EXT_RE.test(name);
161
+
162
+ const hasText = (value: string) => value.trim().length > 0;
163
+
164
+ const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
165
+
166
+ let attachmentCounter = 0;
167
+ const createAttachmentId = () => `chatbar-attachment-${Date.now()}-${++attachmentCounter}`;
168
+
100
169
  /**
101
170
  * Chatbar container and state provider.
102
171
  *
@@ -105,11 +174,12 @@ const useChatbarContext = () => {
105
174
  *
106
175
  * Attachments
107
176
  * - Controlled/uncontrolled attachments with client-side filtering.
108
- * - Filters by `accept`/`pasteAccept`, `maxAttachments`, and `maxFileSize`.
177
+ * - Filters by `accept`/`pasteAccept`, `multiple`, `maxAttachments`, and `maxFileSize`.
109
178
  * - Rejections are reported via `onAttachmentReject`.
110
179
  *
111
180
  * Submit
112
181
  * - `onSubmit({ value, attachments })` emits both message and attachments.
182
+ * - Submitting requires text or at least one attachment.
113
183
  * - `clearOnSubmit` clears message and attachments by default.
114
184
  */
115
185
  interface ChatbarRootBaseProps {
@@ -121,54 +191,60 @@ interface ChatbarRootBaseProps {
121
191
  defaultOpen?: boolean;
122
192
  onOpenChange?: (open: boolean) => void;
123
193
 
194
+ /** When the chatbar expands on its own. `none` leaves open state entirely to the consumer. */
124
195
  expandOn?: ExpandOn;
125
196
 
126
- /** Minimum number of lines in compact state (default: 1) */
197
+ /** Minimum visible lines when expanded (default: 3). The compact state is always one line. */
127
198
  minLines?: number;
128
199
  /** Maximum number of lines before scrolling (default: 6) */
129
200
  maxLines?: number;
130
201
 
202
+ /** Controls Send button visibility only. Enter-to-submit is controlled by `Textarea submitOnEnter`. */
131
203
  sendMode?: SendMode;
132
204
 
205
+ /** Disables text input, submission and every way of adding attachments */
133
206
  disabled?: boolean;
207
+ /** Prevents editing, submission and adding attachments */
134
208
  readOnly?: boolean;
135
209
 
136
210
  /** Combined submit payload */
137
- onSubmit?: (payload: { value: string; attachments: ChatbarAttachment[] }) => void;
211
+ onSubmit?: (payload: ChatbarSubmitPayload) => void;
138
212
 
139
213
  size?: '1' | '2' | '3';
140
214
  variant?: 'surface' | 'outline' | 'classic' | 'ghost' | 'soft';
141
215
  /** Accent color for the control (matches TextArea) */
142
- color?: string;
216
+ color?: (typeof accentColors)[number];
143
217
  /** Optional radius override (matches TextArea) */
144
- radius?: string | number;
218
+ radius?: (typeof radii)[number];
145
219
  /** Panel/material translucency flags (matches TextArea) */
146
220
  panelBackground?: 'solid' | 'translucent';
147
221
  material?: 'solid' | 'translucent';
148
222
 
149
223
  width?: React.CSSProperties['width'];
150
224
  maxWidth?: React.CSSProperties['maxWidth'];
151
- asChild?: boolean;
152
225
 
153
226
  // Attachments API
154
227
  attachments?: ChatbarAttachment[];
155
228
  defaultAttachments?: ChatbarAttachment[];
156
229
  onAttachmentsChange?: (attachments: ChatbarAttachment[]) => void;
157
230
  accept?: string | string[];
231
+ /** Allow more than one file per pick, paste or drop */
158
232
  multiple?: boolean;
159
233
  maxAttachments?: number;
160
234
  maxFileSize?: number;
161
235
  paste?: boolean;
236
+ /** Accepted types for pasted files. Replaces `accept` for paste when set. */
162
237
  pasteAccept?: string | string[];
238
+ /** Clear message and attachments after submit */
163
239
  clearOnSubmit?: boolean;
164
- onAttachmentReject?: (rejections: { file: File; reason: 'type' | 'size' | 'count' }[]) => void;
240
+ onAttachmentReject?: (rejections: ChatbarAttachmentRejection[]) => void;
165
241
 
166
242
  /**
167
243
  * Enables drag-and-drop file uploads when true.
168
244
  *
169
245
  * When enabled:
170
246
  * - Files can be dropped anywhere on the chatbar
171
- * - Same validation rules apply (accept, maxFileSize, maxAttachments)
247
+ * - Same validation rules apply (accept, multiple, maxFileSize, maxAttachments)
172
248
  * - Visual feedback shows during drag operations
173
249
  * - Rejected files trigger onAttachmentReject
174
250
  *
@@ -188,7 +264,7 @@ type RootElement = React.ElementRef<'div'>;
188
264
  export interface ChatbarApi {
189
265
  /** Focus the textarea input */
190
266
  focusTextarea: () => void;
191
- /** Open the file picker dialog (respects accept/multiple) */
267
+ /** Open the file picker dialog (respects accept/multiple). No-op when disabled or read-only. */
192
268
  openFilePicker: () => void;
193
269
  }
194
270
  /**
@@ -198,30 +274,19 @@ export interface ChatbarApi {
198
274
  * - Supports controlled and uncontrolled `value` and `open` states via props.
199
275
  * - Provides context to subcomponents like `Textarea`, `Row`, and `Send`.
200
276
  * - Exposes `data-state`, `data-disabled`, and `data-readonly` attributes for styling.
201
- * - Sets `aria-expanded` to reflect open/closed state for assistive technologies.
202
277
  *
203
278
  * Attachments
204
- * - Controlled/uncontrolled attachments with client-side filtering.
205
- * - Filters by `accept`/`pasteAccept`, `maxAttachments`, and `maxFileSize`.
206
- * - Rejections are reported via `onAttachmentReject`.
207
279
  * - Paste-to-attach: when `paste` is enabled, pasting files adds attachments.
280
+ * - Additions and rejections are announced through a polite live region.
208
281
  *
209
282
  * Dropzone
210
283
  * - When `dropzone` is true, enables drag-and-drop file uploads.
211
- * - Files are validated using the same rules as paste and file picker.
212
284
  * - Visual feedback via `data-drop-active` attribute during drag operations.
213
- * - Rejected files trigger `onAttachmentReject` with appropriate reasons.
214
- *
215
- * Submit
216
- * - `onSubmit` receives both message text and attachments array.
217
- * - `clearOnSubmit` controls whether attachments are cleared after submission.
218
285
  *
219
286
  * Accessibility
220
287
  * - Consumers should label the `Textarea` via `aria-label`/`aria-labelledby`.
221
- * - `aria-expanded` on the root reflects the disclosure state of the input area.
222
- * - Dropzone provides proper ARIA attributes for drag and drop operations.
223
288
  */
224
- interface RootProps extends ComponentPropsWithout<'div', RemovedProps | 'onSubmit'>, ChatbarRootBaseProps {}
289
+ interface RootProps extends ComponentPropsWithout<'div', RemovedProps | 'onSubmit' | 'color'>, ChatbarRootBaseProps {}
225
290
 
226
291
  const Root = React.forwardRef<RootElement, RootProps>((props, forwardedRef) => {
227
292
  const {
@@ -230,10 +295,10 @@ const Root = React.forwardRef<RootElement, RootProps>((props, forwardedRef) => {
230
295
  children,
231
296
  value: valueProp,
232
297
  defaultValue = '',
233
- onValueChange: onValueChangeProp,
298
+ onValueChange,
234
299
  open: openProp,
235
300
  defaultOpen = false,
236
- onOpenChange: onOpenChangeProp,
301
+ onOpenChange,
237
302
  expandOn = 'both',
238
303
  minLines = 3,
239
304
  maxLines = 6,
@@ -249,9 +314,8 @@ const Root = React.forwardRef<RootElement, RootProps>((props, forwardedRef) => {
249
314
  material,
250
315
  width,
251
316
  maxWidth,
252
- asChild,
253
317
  attachments: attachmentsProp,
254
- defaultAttachments = [],
318
+ defaultAttachments,
255
319
  onAttachmentsChange,
256
320
  accept,
257
321
  multiple = true,
@@ -263,404 +327,415 @@ const Root = React.forwardRef<RootElement, RootProps>((props, forwardedRef) => {
263
327
  onAttachmentReject,
264
328
  dropzone = true,
265
329
  apiRef,
330
+ onBlurCapture,
266
331
  ...divProps
267
332
  } = props;
268
333
  const effectiveMaterial = material || panelBackground;
269
334
 
270
335
  const isValueControlled = valueProp != null;
271
336
  const [valueUncontrolled, setValueUncontrolled] = React.useState<string>(defaultValue);
272
- const value = isValueControlled ? (valueProp as string) : valueUncontrolled;
337
+ const value = isValueControlled ? valueProp : valueUncontrolled;
273
338
 
274
339
  const isOpenControlled = openProp != null;
275
340
  const [openUncontrolled, setOpenUncontrolled] = React.useState<boolean>(defaultOpen);
276
- const open = isOpenControlled ? (openProp as boolean) : openUncontrolled;
341
+ const open = isOpenControlled ? openProp : openUncontrolled;
277
342
 
278
- const rootRef = React.useRef<HTMLDivElement>(null);
279
- const textareaRef = React.useRef<HTMLTextAreaElement>(null);
280
- const fileDialogOpenRef = React.useRef<boolean>(false);
281
-
282
- // Attachments state
283
343
  // Treat `attachments` as controlled if the prop is provided, even if its value is `undefined`.
284
344
  // This avoids switching between controlled and uncontrolled when a consumer sets
285
345
  // `attachments={undefined}` to clear attachments. In that case we normalize to an empty array.
286
346
  const isAttachmentsControlled = 'attachments' in props;
287
- const [attachmentsUncontrolled, setAttachmentsUncontrolled] = React.useState<ChatbarAttachment[]>(defaultAttachments);
347
+ const [attachmentsUncontrolled, setAttachmentsUncontrolled] = React.useState<ChatbarAttachment[]>(() => defaultAttachments ?? []);
288
348
  const attachments = React.useMemo(
289
- () => (isAttachmentsControlled ? ((attachmentsProp ?? []) as ChatbarAttachment[]) : attachmentsUncontrolled),
349
+ () => (isAttachmentsControlled ? (attachmentsProp ?? []) : attachmentsUncontrolled),
290
350
  [isAttachmentsControlled, attachmentsProp, attachmentsUncontrolled],
291
351
  );
292
352
 
293
- // Track generated object URLs for cleanup
294
- const generatedUrlSetRef = React.useRef<Set<string>>(new Set());
295
-
296
- const toArray = (val: string | string[] | undefined) => (Array.isArray(val) ? val : typeof val === 'string' ? val.split(',').map((s) => s.trim()) : []);
297
-
298
- const accepts = toArray(accept);
299
- const pasteAccepts = toArray(pasteAccept).length > 0 ? toArray(pasteAccept) : accepts;
300
-
301
- const matchesAccept = (file: File, patterns: string[]) => {
302
- if (patterns.length === 0) return true;
303
- const mime = file.type.toLowerCase();
304
- const name = file.name.toLowerCase();
305
- for (const patRaw of patterns) {
306
- const pat = patRaw.toLowerCase();
307
- if (pat.includes('/')) {
308
- const [type, subtype] = pat.split('/');
309
- const [fmType, fmSubtype] = mime.split('/');
310
- if (type === '*' || (type === fmType && (subtype === '*' || subtype === fmSubtype))) return true;
311
- } else if (pat.startsWith('.')) {
312
- if (name.endsWith(pat)) return true;
313
- }
314
- }
315
- return false;
353
+ // `id` changes on every announcement so identical messages still update the live region
354
+ const [announcement, setAnnouncementState] = React.useState({ text: '', id: 0 });
355
+ const announce = React.useCallback((text: string) => setAnnouncementState((prev) => ({ text, id: prev.id + 1 })), []);
356
+
357
+ const rootRef = React.useRef<HTMLDivElement>(null);
358
+ const composedRootRef = useComposedRefs(forwardedRef, rootRef);
359
+ const textareaRef = React.useRef<HTMLTextAreaElement>(null);
360
+ const fileInputRef = React.useRef<HTMLInputElement>(null);
361
+
362
+ // Blur-collapse guards: `pickerOpenRef` is true while the native dialog is open,
363
+ // `pressGuardRef` covers the gap between pointerdown on a trigger and its click
364
+ // (Safari blurs the textarea before the click fires).
365
+ const pickerOpenRef = React.useRef(false);
366
+ const pressGuardRef = React.useRef(false);
367
+ const pressGuardTimerRef = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
368
+ const pickerRef = React.useRef<{ patterns: string[]; multiple: boolean } | null>(null);
369
+
370
+ const acceptKey = toArray(accept).join(',');
371
+ const pasteAcceptKey = toArray(pasteAccept).join(',');
372
+ const accepts = React.useMemo(() => toArray(acceptKey), [acceptKey]);
373
+ const pasteAccepts = React.useMemo(() => (pasteAcceptKey ? toArray(pasteAcceptKey) : accepts), [pasteAcceptKey, accepts]);
374
+
375
+ // Latest props/state for stable callbacks. Synced during render (not in an effect) so
376
+ // child layout effects that call setOpen/setValue see this render's handlers.
377
+ const snapshot = {
378
+ value,
379
+ attachments,
380
+ accepts,
381
+ pasteAccepts,
382
+ multiple,
383
+ maxAttachments,
384
+ maxFileSize,
385
+ disabled,
386
+ readOnly,
387
+ expandOn,
388
+ isValueControlled,
389
+ isOpenControlled,
390
+ isAttachmentsControlled,
391
+ onValueChange,
392
+ onOpenChange,
393
+ onAttachmentsChange,
394
+ onAttachmentReject,
395
+ onSubmit,
316
396
  };
397
+ const latest = React.useRef(snapshot);
398
+ // eslint-disable-next-line react-hooks/refs -- read only in callbacks/effects; see comment above
399
+ latest.current = snapshot;
400
+
401
+ // Attachments set during the current event, before Root re-renders. Cleared at the end
402
+ // of the task so a controlled parent that rejects the change is not overridden.
403
+ const pendingAttachmentsRef = React.useRef<ChatbarAttachment[] | null>(null);
404
+ const getAttachments = React.useCallback(() => pendingAttachmentsRef.current ?? latest.current.attachments, []);
405
+
406
+ const setOpen = React.useCallback((next: boolean) => {
407
+ if (!latest.current.isOpenControlled) setOpenUncontrolled(next);
408
+ latest.current.onOpenChange?.(next);
409
+ }, []);
410
+
411
+ const setValue = React.useCallback((next: string) => {
412
+ if (!latest.current.isValueControlled) setValueUncontrolled(next);
413
+ latest.current.onValueChange?.(next);
414
+ }, []);
317
415
 
318
- // Refs for values that change frequently, read inside stable callbacks
319
- const attachmentsRef = React.useRef(attachments);
320
- attachmentsRef.current = attachments;
321
- const acceptsRef = React.useRef(accepts);
322
- acceptsRef.current = accepts;
323
- const pasteAcceptsRef = React.useRef(pasteAccepts);
324
- pasteAcceptsRef.current = pasteAccepts;
325
- const maxAttachmentsRef = React.useRef(maxAttachments);
326
- maxAttachmentsRef.current = maxAttachments;
327
- const maxFileSizeRef = React.useRef(maxFileSize);
328
- maxFileSizeRef.current = maxFileSize;
329
- const onAttachmentRejectRef = React.useRef(onAttachmentReject);
330
- onAttachmentRejectRef.current = onAttachmentReject;
331
- const onAttachmentsChangeRef = React.useRef(onAttachmentsChange);
332
- onAttachmentsChangeRef.current = onAttachmentsChange;
333
- const onOpenChangePropRef = React.useRef(onOpenChangeProp);
334
- onOpenChangePropRef.current = onOpenChangeProp;
335
-
336
- const appendFiles = React.useCallback((files: File[]) => {
337
- const currentAttachments = attachmentsRef.current;
338
- const currentAccepts = acceptsRef.current;
339
- const currentMaxAttachments = maxAttachmentsRef.current;
340
- const currentMaxFileSize = maxFileSizeRef.current;
341
-
342
- const next: ChatbarAttachment[] = [];
343
- const rejected: { file: File; reason: 'type' | 'size' | 'count' }[] = [];
344
- const remainingSlots = typeof currentMaxAttachments === 'number' ? Math.max(currentMaxAttachments - currentAttachments.length, 0) : Infinity;
345
-
346
- for (const file of files) {
347
- if (next.length >= remainingSlots) {
348
- rejected.push({ file, reason: 'count' });
349
- continue;
416
+ const setAttachments = React.useCallback((next: ChatbarAttachment[]) => {
417
+ // Back-to-back updates in one event must see each other
418
+ pendingAttachmentsRef.current = next;
419
+ queueMicrotask(() => {
420
+ pendingAttachmentsRef.current = null;
421
+ });
422
+ if (!latest.current.isAttachmentsControlled) setAttachmentsUncontrolled(next);
423
+ latest.current.onAttachmentsChange?.(next);
424
+ }, []);
425
+
426
+ // Object URLs Chatbar created and still owns
427
+ const ownedUrlsRef = React.useRef<Set<string>>(new Set());
428
+
429
+ const addFiles = React.useCallback(
430
+ (files: File[], patterns: string[], multiple: boolean = latest.current.multiple): number => {
431
+ const cfg = latest.current;
432
+ if (cfg.disabled || cfg.readOnly || files.length === 0) return 0;
433
+
434
+ const current = getAttachments();
435
+ const next: ChatbarAttachment[] = [];
436
+ const rejected: ChatbarAttachmentRejection[] = [];
437
+ const remainingSlots = typeof cfg.maxAttachments === 'number' ? Math.max(cfg.maxAttachments - current.length, 0) : Infinity;
438
+ const perActionLimit = multiple ? Infinity : 1;
439
+
440
+ for (const file of files) {
441
+ if (next.length >= remainingSlots || next.length >= perActionLimit) {
442
+ rejected.push({ file, reason: 'count' });
443
+ continue;
444
+ }
445
+ if (typeof cfg.maxFileSize === 'number' && file.size > cfg.maxFileSize) {
446
+ rejected.push({ file, reason: 'size' });
447
+ continue;
448
+ }
449
+ if (!matchesAccept(file, patterns)) {
450
+ rejected.push({ file, reason: 'type' });
451
+ continue;
452
+ }
453
+ const url = isImageLike(file.name, file.type) ? URL.createObjectURL(file) : undefined;
454
+ if (url) ownedUrlsRef.current.add(url);
455
+ next.push({ id: createAttachmentId(), name: file.name, size: file.size, type: file.type, file, url, status: 'idle' });
350
456
  }
351
- if (typeof currentMaxFileSize === 'number' && file.size > currentMaxFileSize) {
352
- rejected.push({ file, reason: 'size' });
353
- continue;
457
+
458
+ const messages: string[] = [];
459
+ if (next.length > 0) {
460
+ setAttachments(current.concat(next));
461
+ if (cfg.expandOn !== 'none') setOpen(true);
462
+ messages.push(`${plural(next.length, 'file')} attached.`);
354
463
  }
355
- if (!matchesAccept(file, currentAccepts)) {
356
- rejected.push({ file, reason: 'type' });
357
- continue;
464
+ if (rejected.length > 0) {
465
+ cfg.onAttachmentReject?.(rejected);
466
+ messages.push(`${plural(rejected.length, 'file')} could not be attached.`);
358
467
  }
359
- const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
360
- const looksLikeImageByExt = /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(file.name);
361
- const isImageType = (file.type || '').toLowerCase().startsWith('image/');
362
- const url = isImageType || looksLikeImageByExt ? URL.createObjectURL(file) : undefined;
363
- if (url) generatedUrlSetRef.current.add(url);
364
- next.push({ id, name: file.name, size: file.size, type: file.type, file, url, status: 'idle' });
365
- }
468
+ if (messages.length > 0) announce(messages.join(' '));
469
+ return next.length;
470
+ },
471
+ [getAttachments, setAttachments, setOpen, announce],
472
+ );
366
473
 
367
- if (next.length > 0) {
368
- const merged = currentAttachments.concat(next);
369
- if (!isAttachmentsControlled) setAttachmentsUncontrolled(merged);
370
- onAttachmentsChangeRef.current?.(merged);
371
- if (!isOpenControlled) setOpenUncontrolled(true);
372
- onOpenChangePropRef.current?.(true);
373
- }
374
- if (rejected.length > 0) onAttachmentRejectRef.current?.(rejected);
375
- }, [isAttachmentsControlled, isOpenControlled]);
376
-
377
- const appendFilesFromPaste = React.useCallback((files: File[]) => {
378
- const currentAccepts = acceptsRef.current;
379
- const currentPasteAccepts = pasteAcceptsRef.current;
380
- const currentMaxAttachments = maxAttachmentsRef.current;
381
- const currentMaxFileSize = maxFileSizeRef.current;
382
- const currentAttachments = attachmentsRef.current;
383
-
384
- const patterns = currentPasteAccepts.length > 0 ? currentPasteAccepts : currentAccepts;
385
- const acceptedFiles: File[] = [];
386
- const rejected: { file: File; reason: 'type' | 'size' | 'count' }[] = [];
387
- const remainingSlots = typeof currentMaxAttachments === 'number' ? Math.max(currentMaxAttachments - currentAttachments.length, 0) : Infinity;
388
-
389
- for (const file of files) {
390
- if (acceptedFiles.length >= remainingSlots) {
391
- rejected.push({ file, reason: 'count' });
392
- continue;
393
- }
394
- if (typeof currentMaxFileSize === 'number' && file.size > currentMaxFileSize) {
395
- rejected.push({ file, reason: 'size' });
396
- continue;
474
+ const appendFiles = React.useCallback((files: File[]) => addFiles(files, latest.current.accepts), [addFiles]);
475
+ const appendFilesFromPaste = React.useCallback((files: File[]) => addFiles(files, latest.current.pasteAccepts), [addFiles]);
476
+
477
+ const removeAttachment = React.useCallback(
478
+ (id: string) => {
479
+ const current = getAttachments();
480
+ const removed = current.find((a) => a.id === id);
481
+ if (!removed) return;
482
+ setAttachments(current.filter((a) => a.id !== id));
483
+ announce(`Removed ${removed.name}.`);
484
+ },
485
+ [getAttachments, setAttachments, announce],
486
+ );
487
+
488
+ const submit = React.useCallback(
489
+ (clear: boolean) => {
490
+ const cfg = latest.current;
491
+ if (cfg.disabled || cfg.readOnly) return false;
492
+ const current = getAttachments();
493
+ if (!hasText(cfg.value) && current.length === 0) return false;
494
+ const payload = { value: cfg.value, attachments: current };
495
+ // Hand preview URLs to the consumer only when they leave the chatbar; URLs still
496
+ // rendered here stay owned so removing them later revokes them
497
+ if (clear && cfg.onSubmit) {
498
+ for (const att of current) if (att.url) ownedUrlsRef.current.delete(att.url);
397
499
  }
398
- if (!matchesAccept(file, patterns)) {
399
- rejected.push({ file, reason: 'type' });
400
- continue;
500
+ cfg.onSubmit?.(payload);
501
+ if (clear) {
502
+ setValue('');
503
+ setAttachments([]);
401
504
  }
402
- acceptedFiles.push(file);
403
- }
404
- if (acceptedFiles.length > 0) appendFiles(acceptedFiles);
405
- if (rejected.length > 0) onAttachmentRejectRef.current?.(rejected);
406
- }, [appendFiles]);
505
+ return true;
506
+ },
507
+ [getAttachments, setValue, setAttachments],
508
+ );
407
509
 
408
- // Revoke object URLs that are no longer referenced by current attachments
510
+ // Revoke owned object URLs that are no longer referenced by current attachments
409
511
  React.useEffect(() => {
410
- const currentUrls = new Set(attachments.map((a) => a.url).filter(Boolean) as string[]);
411
- for (const url of Array.from(generatedUrlSetRef.current)) {
412
- if (!currentUrls.has(url)) {
512
+ const owned = ownedUrlsRef.current;
513
+ if (owned.size === 0) return;
514
+ const current = new Set(attachments.map((a) => a.url));
515
+ for (const url of Array.from(owned)) {
516
+ if (!current.has(url)) {
413
517
  URL.revokeObjectURL(url);
414
- generatedUrlSetRef.current.delete(url);
518
+ owned.delete(url);
415
519
  }
416
520
  }
417
521
  }, [attachments]);
418
522
 
419
- // Revoke any remaining generated URLs on unmount
523
+ // On unmount, revoke URLs only when Chatbar owns the attachment list;
524
+ // controlled attachments may still be rendered by the parent.
420
525
  React.useEffect(() => {
421
- const urlSet = generatedUrlSetRef.current;
526
+ const owned = ownedUrlsRef.current;
422
527
  return () => {
423
- for (const url of Array.from(urlSet)) {
424
- URL.revokeObjectURL(url);
425
- }
426
- urlSet.clear();
528
+ if (latest.current.isAttachmentsControlled) return;
529
+ for (const url of Array.from(owned)) URL.revokeObjectURL(url);
530
+ owned.clear();
427
531
  };
428
532
  }, []);
429
533
 
430
- const Comp = asChild ? Slot : ('div' as any);
534
+ const openFilePicker = React.useCallback((options?: FilePickerOptions) => {
535
+ const cfg = latest.current;
536
+ const input = fileInputRef.current;
537
+ // The picker guard takes over from the press guard
538
+ clearTimeout(pressGuardTimerRef.current);
539
+ pressGuardRef.current = false;
540
+ if (!input || cfg.disabled || cfg.readOnly) return;
541
+ const patterns = options?.accept != null ? toArray(options.accept) : cfg.accepts;
542
+ const multiple = options?.multiple ?? cfg.multiple;
543
+ pickerRef.current = { patterns, multiple };
544
+ input.accept = patterns.join(',');
545
+ input.multiple = multiple;
546
+ pickerOpenRef.current = true;
547
+ input.click();
548
+ // Browsers block the dialog outside a user gesture; if the window kept focus,
549
+ // no dialog opened and no focus/cancel/change event will clear the guard
550
+ setTimeout(() => {
551
+ if (document.hasFocus()) pickerOpenRef.current = false;
552
+ }, 1000);
553
+ }, []);
431
554
 
432
- // Stable context setters — avoids new function references on every render
433
- const handleSetOpen = React.useCallback(
434
- (next: boolean) => {
435
- if (!isOpenControlled) setOpenUncontrolled(next);
436
- onOpenChangeProp?.(next);
437
- },
438
- [isOpenControlled, onOpenChangeProp],
439
- );
440
- const handleSetValue = React.useCallback(
441
- (next: string) => {
442
- if (!isValueControlled) setValueUncontrolled(next);
443
- onValueChangeProp?.(next);
444
- },
445
- [isValueControlled, onValueChangeProp],
446
- );
447
- const handleSetAttachments = React.useCallback(
448
- (next: ChatbarAttachment[]) => {
449
- if (!isAttachmentsControlled) setAttachmentsUncontrolled(next);
450
- onAttachmentsChange?.(next);
555
+ const beginFilePickerPress = React.useCallback(() => {
556
+ // On touch (notably iOS), the compatibility mousedown, blur and click arrive in later
557
+ // tasks than pointerup, so hold the guard until the click or a timeout
558
+ pressGuardRef.current = true;
559
+ clearTimeout(pressGuardTimerRef.current);
560
+ pressGuardTimerRef.current = setTimeout(() => {
561
+ pressGuardRef.current = false;
562
+ }, 800);
563
+ }, []);
564
+
565
+ React.useEffect(() => () => clearTimeout(pressGuardTimerRef.current), []);
566
+
567
+ // Clear the picker guard when the dialog closes, whichever way it closes
568
+ React.useEffect(() => {
569
+ const input = fileInputRef.current;
570
+ const reset = () => {
571
+ pickerOpenRef.current = false;
572
+ };
573
+ window.addEventListener('focus', reset);
574
+ input?.addEventListener('cancel', reset);
575
+ return () => {
576
+ window.removeEventListener('focus', reset);
577
+ input?.removeEventListener('cancel', reset);
578
+ };
579
+ }, []);
580
+
581
+ const handleFileInputChange = React.useCallback(
582
+ (event: React.ChangeEvent<HTMLInputElement>) => {
583
+ const input = event.currentTarget;
584
+ const files = Array.from(input.files ?? []);
585
+ const picker = pickerRef.current;
586
+ addFiles(files, picker?.patterns ?? latest.current.accepts, picker?.multiple);
587
+ pickerOpenRef.current = false;
588
+ pickerRef.current = null;
589
+ // Reset so selecting the same file again still fires change
590
+ input.value = '';
451
591
  },
452
- [isAttachmentsControlled, onAttachmentsChange],
592
+ [addFiles],
453
593
  );
454
594
 
455
595
  const handleBlurCapture = React.useCallback(
456
- (event: React.FocusEvent) => {
457
- const nextTarget = event.relatedTarget as Node | null;
596
+ (event: React.FocusEvent<HTMLDivElement>) => {
597
+ onBlurCapture?.(event);
598
+ const cfg = latest.current;
458
599
  const rootEl = rootRef.current;
459
- if (!rootEl) return;
460
- // If focus remains within root, ignore
600
+ if (!rootEl || cfg.expandOn === 'none') return;
601
+ const nextTarget = event.relatedTarget as Node | null;
461
602
  if (nextTarget && rootEl.contains(nextTarget)) return;
462
- // If native file dialog is open, avoid collapsing on blur
463
- if (fileDialogOpenRef.current) return;
464
- // Collapse when leaving the root if the value is empty
465
- // Only collapse when both message and attachments are empty
466
- if ((value?.trim?.() ?? '').length === 0 && attachments.length === 0) {
467
- if (!isOpenControlled) setOpenUncontrolled(false);
468
- onOpenChangeProp?.(false);
469
- }
603
+ if (pickerOpenRef.current || pressGuardRef.current) return;
604
+ if (!hasText(cfg.value) && getAttachments().length === 0) setOpen(false);
470
605
  },
471
- [isOpenControlled, onOpenChangeProp, value, attachments],
606
+ [onBlurCapture, setOpen, getAttachments],
472
607
  );
473
608
 
474
- // Dropzone functionality
475
- const {
476
- getRootProps,
477
- getInputProps,
478
- isDragActive,
479
- open: openFileDialog,
480
- } = useDropzone({
481
- onDrop: (acceptedFiles, rejectedFiles) => {
482
- if (acceptedFiles.length > 0) {
483
- appendFiles(acceptedFiles);
484
- }
485
- if (rejectedFiles.length > 0 && onAttachmentReject) {
486
- const rejections = rejectedFiles.map(({ file, errors }) => {
487
- const reason = errors[0]?.code === 'file-too-large' ? 'size' : errors[0]?.code === 'file-invalid-type' ? 'type' : 'count';
488
- return { file, reason: reason as 'type' | 'size' | 'count' };
489
- });
490
- onAttachmentReject(rejections);
491
- }
492
- },
493
- accept:
494
- accepts.length > 0
495
- ? accepts.reduce(
496
- (acc, pattern) => {
497
- if (pattern.includes('/')) {
498
- // MIME type pattern
499
- acc[pattern] = [];
500
- } else if (pattern.startsWith('.')) {
501
- // File extension pattern
502
- acc[pattern] = [];
503
- }
504
- return acc;
505
- },
506
- {} as Record<string, string[]>,
507
- )
508
- : undefined,
509
- multiple,
510
- maxSize: maxFileSize,
609
+ const handleDrop = React.useCallback((files: File[]) => {
610
+ appendFiles(files);
611
+ }, [appendFiles]);
612
+
613
+ // Validation happens in addFiles so drop, paste and picker share one rule set
614
+ const { getRootProps, isDragActive } = useDropzone({
615
+ onDrop: handleDrop,
616
+ // Drop any earlier message so it is not re-announced when the drag hint goes away
617
+ onDragEnter: () => announce(''),
511
618
  noClick: true,
512
619
  noKeyboard: true,
513
- disabled: !dropzone || disabled,
620
+ // Textarea handles paste itself; letting the dropzone see it attaches twice
621
+ noPaste: true,
622
+ disabled: !dropzone || disabled || readOnly,
514
623
  });
624
+ const dragActive = dropzone && isDragActive;
515
625
 
516
- // Expose imperative API via apiRef (non-breaking; forwardedRef still receives the DOM node)
517
626
  React.useImperativeHandle(
518
627
  apiRef,
519
628
  () => ({
520
629
  focusTextarea: () => textareaRef.current?.focus({ preventScroll: true }),
521
- openFilePicker: () => {
522
- // Guard against blur-collapse while native dialog is open
523
- fileDialogOpenRef.current = true;
524
- openFileDialog();
525
- },
630
+ openFilePicker: () => openFilePicker(),
526
631
  }),
527
- [openFileDialog],
632
+ [openFilePicker],
528
633
  );
529
634
 
530
635
  // Click-to-focus: focus textarea when clicking non-interactive areas inside the container
531
- const isInteractiveTarget = React.useCallback((target: EventTarget | null) => {
532
- if (!(target instanceof Element)) return false;
533
- const el = target as Element;
534
- if (el.closest('.rt-ChatbarDropOverlay')) return true;
535
- return !!el.closest('button, [role="button"], a[href], input, textarea, select, [contenteditable], [tabindex]:not([tabindex="-1"])');
536
- }, []);
537
-
538
636
  const handleContainerPointerDown = React.useCallback(
539
637
  (event: React.PointerEvent) => {
540
638
  if (disabled) return;
541
- if (isInteractiveTarget(event.target)) return;
542
639
  if (event.pointerType === 'mouse' && event.button !== 0) return;
640
+ const target = event.target;
641
+ if (target instanceof Element && target.closest('button, [role="button"], a[href], input, textarea, select, [contenteditable], [tabindex]:not([tabindex="-1"])')) return;
543
642
  event.preventDefault();
544
643
  textareaRef.current?.focus({ preventScroll: true });
545
644
  },
546
- [disabled, isInteractiveTarget, textareaRef],
645
+ [disabled],
547
646
  );
548
647
 
549
- // Clicking the label-wrapped Card will naturally focus the nested textarea.
648
+ const contextValue = React.useMemo<ChatbarContextValue>(
649
+ () => ({
650
+ open,
651
+ setOpen,
652
+ setValue,
653
+ size,
654
+ expandOn,
655
+ minLines,
656
+ maxLines,
657
+ sendMode,
658
+ disabled,
659
+ readOnly,
660
+ paste,
661
+ clearOnSubmit,
662
+ multiple,
663
+ textareaRef,
664
+ attachments,
665
+ removeAttachment,
666
+ submit,
667
+ appendFiles,
668
+ appendFilesFromPaste,
669
+ openFilePicker,
670
+ beginFilePickerPress,
671
+ }),
672
+ [
673
+ open, setOpen, setValue, size, expandOn, minLines, maxLines, sendMode, disabled, readOnly, paste,
674
+ clearOnSubmit, multiple, attachments, removeAttachment, submit, appendFiles, appendFilesFromPaste,
675
+ openFilePicker, beginFilePickerPress,
676
+ ],
677
+ );
550
678
 
551
679
  return (
552
- <ChatbarContext.Provider
553
- value={React.useMemo<ChatbarContextValue>(
554
- () => ({
555
- open,
556
- setOpen: handleSetOpen,
557
- isOpenControlled,
558
- value,
559
- setValue: handleSetValue,
560
- isValueControlled,
561
- size,
562
- expandOn,
563
- minLines,
564
- maxLines,
565
- sendMode,
566
- disabled,
567
- readOnly,
568
- onSubmit,
569
- rootRef,
570
- textareaRef,
571
- attachments,
572
- setAttachments: handleSetAttachments,
573
- isAttachmentsControlled,
574
- accept,
575
- multiple,
576
- maxAttachments,
577
- maxFileSize,
578
- paste,
579
- pasteAccept,
580
- clearOnSubmit,
581
- onAttachmentReject,
582
- dropzone,
583
- appendFiles,
584
- appendFilesFromPaste,
585
- fileDialogOpenRef,
586
- }),
587
- [
588
- open, handleSetOpen, isOpenControlled,
589
- value, handleSetValue, isValueControlled,
590
- size, expandOn, minLines, maxLines, sendMode,
591
- disabled, readOnly, onSubmit,
592
- attachments, handleSetAttachments, isAttachmentsControlled,
593
- accept, multiple, maxAttachments, maxFileSize,
594
- paste, pasteAccept, clearOnSubmit, onAttachmentReject,
595
- dropzone, appendFiles, appendFilesFromPaste,
596
- ],
597
- )}
598
- >
599
- <LazyMotion features={domAnimation}>
600
- <Comp
601
- {...divProps}
602
- ref={(node: HTMLDivElement) => {
603
- if (typeof forwardedRef === 'function') forwardedRef(node);
604
- else if (forwardedRef) (forwardedRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
605
- (rootRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
606
- }}
607
- className={classNames('rt-ChatbarRoot', `rt-r-size-${size}`, className)}
608
- style={{ position: 'relative', width, maxWidth, ...style }}
609
- data-state={open ? 'open' : 'closed'}
610
- data-disabled={disabled ? '' : undefined}
611
- data-readonly={readOnly ? '' : undefined}
612
- data-drop-active={dropzone && isDragActive ? '' : undefined}
613
- data-accent-color={color}
614
- data-radius={radius as any}
615
- data-panel-background={effectiveMaterial}
616
- data-material={effectiveMaterial}
617
- aria-expanded={open}
618
- onBlurCapture={handleBlurCapture}
619
- >
620
- {dropzone && <input {...getInputProps()} />}
621
- <div {...(dropzone ? getRootProps() : {})} style={{ width: '100%', height: '100%' }} onPointerDown={handleContainerPointerDown}>
622
- <m.div
623
- className={classNames('rt-ChatbarBox', `rt-variant-${variant ?? 'surface'}`)}
624
- style={{ position: 'relative' }}
680
+ <ChatbarContext.Provider value={contextValue}>
681
+ <ChatbarValueContext.Provider value={value}>
682
+ <LazyMotion features={domAnimation}>
683
+ <MotionConfig reducedMotion="user">
684
+ <div
685
+ {...divProps}
686
+ ref={composedRootRef}
687
+ className={classNames('rt-ChatbarRoot', `rt-r-size-${size}`, className)}
688
+ style={{ position: 'relative', width, maxWidth, ...style }}
689
+ data-state={open ? 'open' : 'closed'}
690
+ data-disabled={disabled ? '' : undefined}
691
+ data-readonly={readOnly ? '' : undefined}
692
+ data-drop-active={dragActive ? '' : undefined}
625
693
  data-accent-color={color}
626
- data-radius={radius as any}
694
+ data-radius={radius}
627
695
  data-panel-background={effectiveMaterial}
628
696
  data-material={effectiveMaterial}
629
- layout
630
- transition={{
631
- layout: {
632
- type: 'spring',
633
- visualDuration: 0.15,
634
- bounce: 0.1,
635
- },
636
- }}
697
+ onBlurCapture={handleBlurCapture}
637
698
  >
638
- <m.div
639
- className="rt-ChatbarGrid"
640
- layout="position"
641
- transition={{
642
- layout: {
643
- type: 'spring',
644
- visualDuration: 0.15,
645
- bounce: 0.1,
646
- },
647
- }}
648
- >
649
- {children}
650
- </m.div>
651
- {dropzone && isDragActive && (
652
- <div className="rt-ChatbarDropOverlay">
653
- <div className="rt-ChatbarDropContent">
654
- <Text color="gray" size={size} weight="medium">
655
- Drop files here to attach
656
- </Text>
657
- </div>
658
- </div>
659
- )}
660
- </m.div>
661
- </div>
662
- </Comp>
663
- </LazyMotion>
699
+ <input
700
+ ref={fileInputRef}
701
+ type="file"
702
+ tabIndex={-1}
703
+ aria-hidden
704
+ style={{ display: 'none' }}
705
+ onChange={handleFileInputChange}
706
+ />
707
+ <div {...(dropzone ? getRootProps() : {})} style={{ width: '100%', height: '100%' }} onPointerDown={handleContainerPointerDown}>
708
+ <m.div
709
+ className={classNames('rt-ChatbarBox', `rt-variant-${variant ?? 'surface'}`)}
710
+ style={{ position: 'relative' }}
711
+ data-accent-color={color}
712
+ data-radius={radius}
713
+ data-panel-background={effectiveMaterial}
714
+ data-material={effectiveMaterial}
715
+ layout
716
+ transition={LAYOUT_TRANSITION}
717
+ >
718
+ <m.div className="rt-ChatbarGrid" layout="position" transition={LAYOUT_TRANSITION}>
719
+ {children}
720
+ </m.div>
721
+ {dragActive && (
722
+ <div className="rt-ChatbarDropOverlay" aria-hidden>
723
+ <div className="rt-ChatbarDropContent">
724
+ <Text color="gray" size={size} weight="medium">
725
+ Drop files here to attach
726
+ </Text>
727
+ </div>
728
+ </div>
729
+ )}
730
+ </m.div>
731
+ </div>
732
+ <VisuallyHidden role="status" aria-live="polite">
733
+ {dragActive ? 'Drop files here to attach.' : <span key={announcement.id}>{announcement.text}</span>}
734
+ </VisuallyHidden>
735
+ </div>
736
+ </MotionConfig>
737
+ </LazyMotion>
738
+ </ChatbarValueContext.Provider>
664
739
  </ChatbarContext.Provider>
665
740
  );
666
741
  });
@@ -674,15 +749,11 @@ Root.displayName = 'Chatbar.Root';
674
749
  * - Paste-to-attach: when `paste` is enabled on Root, pasting files adds attachments.
675
750
  * - Provide `aria-label` or `aria-labelledby` for an accessible name.
676
751
  */
677
- interface TextareaProps extends Omit<React.ComponentPropsWithoutRef<'textarea'>, 'size'> {
752
+ interface TextareaProps extends Omit<React.ComponentPropsWithoutRef<'textarea'>, 'size' | 'rows' | 'value' | 'defaultValue'> {
678
753
  asChild?: boolean;
679
754
  /**
680
- * Handler for paste events. This is forwarded to the underlying <textarea>.
681
- */
682
- onPaste?: React.ClipboardEventHandler<HTMLTextAreaElement>;
683
- /**
684
- * When true, pressing Enter submits via onSend (Shift+Enter inserts newline).
685
- * Defaults to false.
755
+ * When true, pressing Enter submits (Shift+Enter inserts a newline).
756
+ * Works with every `sendMode`. Defaults to false.
686
757
  */
687
758
  submitOnEnter?: boolean;
688
759
  }
@@ -691,125 +762,113 @@ interface TextareaProps extends Omit<React.ComponentPropsWithoutRef<'textarea'>,
691
762
  * Chatbar multi-line text input.
692
763
  *
693
764
  * Behavior
694
- * - Controls the Chatbar value via React onChange. We intentionally do not
695
- * update state in onInput to avoid duplicate updates per keystroke.
696
- * - Auto-resizes between minLines and maxLines using layout measurements.
697
- * - When expandOn is `overflow` or `both`, the Chatbar opens as soon as the
698
- * content exceeds the compact height.
699
- * - Height recalculations occur on change, paste, and whenever `value` or `open`
700
- * changes via an isomorphic layout effect to avoid SSR warnings.
765
+ * - Controls the Chatbar value via React onChange.
766
+ * - Auto-resizes between minLines and maxLines in a single layout effect, which
767
+ * also opens the Chatbar when content overflows the compact height and
768
+ * `expandOn` is `overflow` or `both`.
769
+ * - Consumer handlers run first; calling `preventDefault` in `onKeyDown` or
770
+ * `onPaste` skips the built-in submit or attach behavior.
701
771
  *
702
772
  * Accessibility
703
773
  * - Consumers should provide labeling via aria-label or aria-labelledby
704
774
  * on this component, as no implicit label is rendered.
705
775
  */
706
776
  const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>((props, forwardedRef) => {
707
- const { className, style, asChild, onFocus, onInput, onChange, onPaste, onKeyDown, submitOnEnter = false, rows, ...textareaProps } = props;
777
+ const {
778
+ className,
779
+ style,
780
+ asChild,
781
+ onFocus,
782
+ onChange,
783
+ onPaste,
784
+ onKeyDown,
785
+ onCompositionStart,
786
+ onCompositionEnd,
787
+ submitOnEnter = false,
788
+ ...textareaProps
789
+ } = props;
708
790
  const ctx = useChatbarContext();
709
- const { open, minLines, maxLines, expandOn, disabled, readOnly, setOpen, setValue, textareaRef, value, isValueControlled, sendMode, paste, appendFilesFromPaste, size } = ctx;
791
+ const value = React.useContext(ChatbarValueContext);
792
+ const { open, minLines, maxLines, expandOn, disabled, readOnly, setOpen, setValue, textareaRef, paste, appendFilesFromPaste, size, submit, clearOnSubmit } = ctx;
793
+ const composedRef = useComposedRefs(forwardedRef, textareaRef);
710
794
 
711
795
  // Cached metrics to avoid repeated getComputedStyle calls
712
- const lineHeightRef = React.useRef<number>(0);
713
- const paddingRef = React.useRef<number>(0);
714
- const compactHeightRef = React.useRef<number>(0);
796
+ const lineHeightRef = React.useRef(0);
797
+ const paddingRef = React.useRef(0);
798
+ const composingRef = React.useRef(false);
799
+
800
+ const sizingRef = React.useRef({ open, minLines, maxLines });
801
+ // Declared before the sizing effect so it runs first in the same commit
802
+ useIsomorphicLayoutEffect(() => {
803
+ sizingRef.current = { open, minLines, maxLines };
804
+ }, [open, minLines, maxLines]);
715
805
 
716
806
  const recomputeMetrics = React.useCallback(() => {
717
807
  const el = textareaRef.current;
718
808
  if (!el) return;
719
809
  const computedStyle = window.getComputedStyle(el);
720
- const lineHeight = parseFloat(computedStyle.lineHeight) || 20;
721
- const paddingTop = parseFloat(computedStyle.paddingTop) || 0;
722
- const paddingBottom = parseFloat(computedStyle.paddingBottom) || 0;
723
- lineHeightRef.current = lineHeight;
724
- paddingRef.current = paddingTop + paddingBottom;
725
- compactHeightRef.current = Math.ceil(1 * lineHeight) + paddingTop + paddingBottom;
810
+ lineHeightRef.current = parseFloat(computedStyle.lineHeight) || 20;
811
+ paddingRef.current = (parseFloat(computedStyle.paddingTop) || 0) + (parseFloat(computedStyle.paddingBottom) || 0);
726
812
  }, [textareaRef]);
727
813
 
728
- // Auto-resize logic - optimized for fixed widths
729
- const updateHeight = React.useCallback(
730
- (forceOpen?: boolean) => {
731
- const textarea = textareaRef.current;
732
- if (!textarea) return;
733
-
734
- textarea.style.height = 'auto';
735
-
736
- if (lineHeightRef.current === 0) {
737
- recomputeMetrics();
738
- }
739
- const lineHeight = lineHeightRef.current;
740
- const padding = paddingRef.current;
741
-
742
- const isOpen = forceOpen ?? open;
743
- const effectiveMinLines = isOpen ? minLines : 1;
744
- const effectiveMaxLines = isOpen ? maxLines : 1;
745
-
746
- const minHeight = Math.ceil(effectiveMinLines * lineHeight) + padding;
747
- const maxHeight = Math.ceil(effectiveMaxLines * lineHeight) + padding;
748
-
749
- const contentHeight = Math.max(textarea.scrollHeight, minHeight);
750
- const finalHeight = Math.min(contentHeight, maxHeight);
751
-
752
- textarea.style.height = `${finalHeight}px`;
753
-
754
- if (contentHeight > maxHeight) {
755
- textarea.style.overflowY = 'auto';
756
- textarea.style.maxHeight = `${maxHeight}px`;
757
- } else {
758
- textarea.style.overflowY = 'hidden';
759
- textarea.style.maxHeight = 'none';
760
- }
761
- },
762
- [open, minLines, maxLines, textareaRef, recomputeMetrics],
763
- );
814
+ // Sizes `el` for the given open state; caller must have set height to 'auto' first
815
+ const applyHeight = React.useCallback((el: HTMLTextAreaElement, isOpen: boolean) => {
816
+ const { minLines: min, maxLines: max } = sizingRef.current;
817
+ const lineHeight = lineHeightRef.current;
818
+ const padding = paddingRef.current;
819
+ const minHeight = Math.ceil((isOpen ? min : 1) * lineHeight) + padding;
820
+ const maxHeight = Math.ceil((isOpen ? max : 1) * lineHeight) + padding;
821
+ const contentHeight = Math.max(el.scrollHeight, minHeight);
822
+ el.style.height = `${Math.min(contentHeight, maxHeight)}px`;
823
+ const overflowing = contentHeight > maxHeight;
824
+ el.style.overflowY = overflowing ? 'auto' : 'hidden';
825
+ el.style.maxHeight = overflowing ? `${maxHeight}px` : 'none';
826
+ }, []);
764
827
 
765
- // Update height when value or open state changes
828
+ // Typography depends on size
766
829
  useIsomorphicLayoutEffect(() => {
767
- updateHeight();
768
- }, [updateHeight, value, open]);
830
+ recomputeMetrics();
831
+ }, [recomputeMetrics, size]);
769
832
 
770
- // Auto-open on external value changes when content overflows one line
833
+ // Single place for sizing and overflow auto-open: one height reset, one scrollHeight read
771
834
  useIsomorphicLayoutEffect(() => {
772
- if (!(expandOn === 'overflow' || expandOn === 'both')) return;
773
- if (open) return;
774
835
  const el = textareaRef.current;
775
836
  if (!el) return;
776
- // Measure overflow against compact (1-line) height
777
- el.style.height = 'auto';
778
- if (compactHeightRef.current === 0) {
779
- recomputeMetrics();
780
- }
781
- const shouldExpand = el.scrollHeight > compactHeightRef.current + 1;
782
- if (shouldExpand) {
783
- setOpen(true);
784
- // Immediately size for open state
785
- updateHeight(true);
786
- requestAnimationFrame(() => updateHeight(true));
787
- }
788
- }, [value, expandOn, open, setOpen, textareaRef, recomputeMetrics, updateHeight]);
789
-
790
- // Recompute metrics on mount and when size changes may affect typography
791
- useIsomorphicLayoutEffect(() => {
792
- recomputeMetrics();
793
- updateHeight();
794
- }, [recomputeMetrics, updateHeight, size]);
837
+ if (lineHeightRef.current === 0) recomputeMetrics();
838
+ withoutPlaceholder(el, () => {
839
+ el.style.height = 'auto';
840
+ let isOpen = open;
841
+ if (!open && (expandOn === 'overflow' || expandOn === 'both')) {
842
+ const compactHeight = Math.ceil(lineHeightRef.current) + paddingRef.current;
843
+ if (el.scrollHeight > compactHeight + 1) {
844
+ isOpen = true;
845
+ setOpen(true);
846
+ }
847
+ }
848
+ applyHeight(el, isOpen);
849
+ });
850
+ }, [value, open, expandOn, minLines, maxLines, size, setOpen, textareaRef, recomputeMetrics, applyHeight]);
795
851
 
796
- // Observe responsive changes that alter line-height
852
+ // Width changes re-wrap text; re-measure only when the width actually changes
797
853
  React.useEffect(() => {
798
854
  const el = textareaRef.current;
799
855
  if (!el || typeof ResizeObserver === 'undefined') return;
800
- let prevLineHeight = lineHeightRef.current;
801
- const ro = new ResizeObserver(() => {
802
- const computedStyle = window.getComputedStyle(el);
803
- const lh = parseFloat(computedStyle.lineHeight) || 20;
804
- if (lh !== prevLineHeight) {
805
- prevLineHeight = lh;
806
- recomputeMetrics();
807
- requestAnimationFrame(() => updateHeight());
808
- }
856
+ let prevWidth = -1;
857
+ const ro = new ResizeObserver((entries) => {
858
+ const width = entries[0]?.contentRect.width ?? 0;
859
+ if (width === prevWidth) return;
860
+ const first = prevWidth === -1;
861
+ prevWidth = width;
862
+ if (first) return;
863
+ recomputeMetrics();
864
+ withoutPlaceholder(el, () => {
865
+ el.style.height = 'auto';
866
+ applyHeight(el, sizingRef.current.open);
867
+ });
809
868
  });
810
869
  ro.observe(el);
811
870
  return () => ro.disconnect();
812
- }, [textareaRef, recomputeMetrics, updateHeight]);
871
+ }, [textareaRef, recomputeMetrics, applyHeight]);
813
872
 
814
873
  // Dev-only warning if no accessible name is provided
815
874
  React.useEffect(() => {
@@ -822,134 +881,82 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>((props, fo
822
881
  // eslint-disable-next-line react-hooks/exhaustive-deps
823
882
  }, []);
824
883
 
825
- // Note: No MutationObserver is used because <textarea> value changes are not
826
- // reflected in DOM text nodes. Height updates are handled by effects and events.
827
-
828
884
  const handleFocus = React.useCallback<React.FocusEventHandler<HTMLTextAreaElement>>(
829
885
  (event) => {
886
+ onFocus?.(event);
830
887
  if (disabled || readOnly) return;
831
888
  if ((expandOn === 'focus' || expandOn === 'both') && !open) setOpen(true);
832
- onFocus?.(event);
833
889
  },
834
890
  [disabled, readOnly, expandOn, open, setOpen, onFocus],
835
891
  );
836
892
 
837
893
  const handleChange = React.useCallback<React.ChangeEventHandler<HTMLTextAreaElement>>(
838
894
  (event) => {
839
- const el = event.currentTarget;
840
- const nextValue = el.value;
841
- setValue(nextValue);
842
-
843
- if ((expandOn === 'overflow' || expandOn === 'both') && !open) {
844
- el.style.height = 'auto';
845
-
846
- if (compactHeightRef.current === 0) {
847
- recomputeMetrics();
848
- }
849
- const shouldExpand = el.scrollHeight > compactHeightRef.current + 1;
850
- if (shouldExpand) {
851
- setOpen(true);
852
- // Immediately size for open state to avoid 1-line + scrollbar flash
853
- updateHeight(true);
854
- requestAnimationFrame(() => updateHeight(true));
855
- }
856
- }
857
-
858
- // Always recalc after any input
859
- requestAnimationFrame(() => updateHeight());
895
+ setValue(event.currentTarget.value);
860
896
  onChange?.(event);
861
897
  },
862
- [expandOn, open, setOpen, setValue, onChange, updateHeight, recomputeMetrics],
898
+ [setValue, onChange],
863
899
  );
864
900
 
865
901
  const handlePaste = React.useCallback<React.ClipboardEventHandler<HTMLTextAreaElement>>(
866
902
  (event) => {
867
- // Attach files from clipboard if enabled
868
- if (paste) {
869
- const items = Array.from(event.clipboardData?.items ?? []);
870
- const files = items
871
- .filter((i) => i.kind === 'file')
872
- .map((i) => i.getAsFile())
873
- .filter((f): f is File => !!f);
874
- if (files.length > 0) {
875
- // Prevent pasting the file name or any text representation when files are present
876
- event.preventDefault();
877
- appendFilesFromPaste(files);
878
- }
879
- }
880
- setTimeout(() => {
881
- // If pasting in compact mode, force sizing as open if content overflowed
882
- if (!open) {
883
- updateHeight(true);
884
- } else {
885
- updateHeight();
886
- }
887
- }, 0);
888
903
  onPaste?.(event);
904
+ if (event.defaultPrevented || !paste || disabled || readOnly) return;
905
+ const files = Array.from(event.clipboardData?.items ?? [])
906
+ .filter((item) => item.kind === 'file')
907
+ .map((item) => item.getAsFile())
908
+ .filter((f): f is File => !!f);
909
+ if (files.length === 0) return;
910
+ // Suppress the filename/text representation only when a file was actually attached
911
+ if (appendFilesFromPaste(files) > 0) event.preventDefault();
912
+ },
913
+ [paste, disabled, readOnly, onPaste, appendFilesFromPaste],
914
+ );
915
+
916
+ const handleCompositionStart = React.useCallback<React.CompositionEventHandler<HTMLTextAreaElement>>(
917
+ (event) => {
918
+ composingRef.current = true;
919
+ onCompositionStart?.(event);
920
+ },
921
+ [onCompositionStart],
922
+ );
923
+
924
+ const handleCompositionEnd = React.useCallback<React.CompositionEventHandler<HTMLTextAreaElement>>(
925
+ (event) => {
926
+ composingRef.current = false;
927
+ onCompositionEnd?.(event);
889
928
  },
890
- [paste, open, updateHeight, onPaste, appendFilesFromPaste],
929
+ [onCompositionEnd],
891
930
  );
892
931
 
893
932
  const handleKeyDown = React.useCallback<React.KeyboardEventHandler<HTMLTextAreaElement>>(
894
933
  (event) => {
895
- if (!submitOnEnter) {
896
- onKeyDown?.(event);
897
- return;
898
- }
899
- if (event.key === 'Enter' && !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey && !event.nativeEvent.isComposing) {
900
- if (disabled || readOnly) {
901
- onKeyDown?.(event);
902
- return;
903
- }
904
- if (sendMode === 'never') {
905
- onKeyDown?.(event);
906
- return;
907
- }
908
- const trimmed = value.trim();
909
- const hasContent = trimmed.length > 0 || ctx.attachments.length > 0;
910
- if (sendMode === 'whenDirty' && !hasContent) {
911
- onKeyDown?.(event);
912
- return;
913
- }
914
- event.preventDefault();
915
- ctx.onSubmit?.({ value, attachments: ctx.attachments });
916
- if (ctx.clearOnSubmit) {
917
- if (!isValueControlled) setValue('');
918
- ctx.setAttachments([]);
919
- }
920
- }
921
934
  onKeyDown?.(event);
935
+ if (event.defaultPrevented || !submitOnEnter) return;
936
+ if (event.key !== 'Enter' || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return;
937
+ // Safari fires the IME-confirming Enter after compositionend with keyCode 229
938
+ if (composingRef.current || event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
939
+ // Plain Enter never inserts a newline when submitOnEnter is on, even with nothing to send
940
+ event.preventDefault();
941
+ submit(clearOnSubmit);
922
942
  },
923
- [submitOnEnter, disabled, readOnly, sendMode, value, isValueControlled, setValue, ctx, onKeyDown],
943
+ [submitOnEnter, onKeyDown, submit, clearOnSubmit],
924
944
  );
925
945
 
926
- const Comp = asChild ? Slot : ('textarea' as any);
946
+ const Comp = asChild ? Slot : 'textarea';
927
947
  return (
928
- <m.div
929
- className={classNames('rt-ChatbarField', 'rt-ChatbarTextarea', className)}
930
- layout="position"
931
- transition={{
932
- layout: {
933
- type: 'spring',
934
- visualDuration: 0.15,
935
- bounce: 0.1,
936
- },
937
- }}
938
- >
948
+ <m.div className={classNames('rt-ChatbarField', 'rt-ChatbarTextarea', className)} layout="position" transition={LAYOUT_TRANSITION}>
939
949
  <Comp
940
950
  {...textareaProps}
941
- ref={(node: HTMLTextAreaElement) => {
942
- if (typeof forwardedRef === 'function') forwardedRef(node);
943
- else if (forwardedRef) (forwardedRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = node;
944
- (textareaRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = node;
945
- }}
951
+ ref={composedRef}
946
952
  className="rt-ChatbarInput"
947
953
  value={value}
948
- onInput={onInput}
949
954
  onChange={handleChange}
950
955
  onFocus={handleFocus}
951
956
  onPaste={handlePaste}
952
957
  onKeyDown={handleKeyDown}
958
+ onCompositionStart={handleCompositionStart}
959
+ onCompositionEnd={handleCompositionEnd}
953
960
  disabled={disabled}
954
961
  readOnly={readOnly}
955
962
  rows={open ? minLines : 1}
@@ -968,12 +975,12 @@ interface InlineSlotProps extends Omit<React.ComponentPropsWithoutRef<'div'>, 'c
968
975
  }
969
976
 
970
977
  const InlineStart = React.forwardRef<HTMLDivElement, InlineSlotProps>((props, forwardedRef) => {
971
- const { children, asChild, style, className, ...divProps } = props;
978
+ const { children, asChild, className, ...divProps } = props;
972
979
  const ctx = useChatbarContext();
973
980
  if (ctx.open) return null;
974
- const Comp = asChild ? Slot : ('div' as any);
981
+ const Comp = asChild ? Slot : 'div';
975
982
  return (
976
- <Comp {...divProps} ref={forwardedRef} className={classNames('rt-ChatbarInlineStart', className)} style={style}>
983
+ <Comp {...divProps} ref={forwardedRef} className={classNames('rt-ChatbarInlineStart', className)}>
977
984
  {children}
978
985
  </Comp>
979
986
  );
@@ -981,13 +988,12 @@ const InlineStart = React.forwardRef<HTMLDivElement, InlineSlotProps>((props, fo
981
988
  InlineStart.displayName = 'Chatbar.InlineStart';
982
989
 
983
990
  const InlineEnd = React.forwardRef<HTMLDivElement, InlineSlotProps>((props, forwardedRef) => {
984
- const { children, asChild, style, className, ...divProps } = props;
991
+ const { children, asChild, className, ...divProps } = props;
985
992
  const ctx = useChatbarContext();
986
- // Use CSS to hide when open instead of unmounting to avoid layout animation scale
987
993
  if (ctx.open) return null;
988
- const Comp = asChild ? Slot : ('div' as any);
994
+ const Comp = asChild ? Slot : 'div';
989
995
  return (
990
- <Comp {...divProps} ref={forwardedRef} className={classNames('rt-ChatbarInlineEnd', className)} style={style}>
996
+ <Comp {...divProps} ref={forwardedRef} className={classNames('rt-ChatbarInlineEnd', className)}>
991
997
  {children}
992
998
  </Comp>
993
999
  );
@@ -1006,25 +1012,15 @@ interface AttachmentsRowProps extends Omit<React.ComponentPropsWithoutRef<'div'>
1006
1012
  }
1007
1013
 
1008
1014
  const AttachmentsRow = React.forwardRef<HTMLDivElement, AttachmentsRowProps>((props, forwardedRef) => {
1009
- const { asChild, forceMount, renderAttachment, className, style, ...divProps } = props;
1015
+ const { asChild, forceMount, renderAttachment, className, ...divProps } = props;
1010
1016
  const ctx = useChatbarContext();
1011
- const hasItems = ctx.attachments.length > 0;
1012
- if (!hasItems && !forceMount) return null;
1013
- const Comp = asChild ? Slot : ('div' as any);
1017
+ if (ctx.attachments.length === 0 && !forceMount) return null;
1018
+ const Comp = asChild ? Slot : 'div';
1014
1019
  return (
1015
- <m.div
1016
- layout="position"
1017
- transition={{
1018
- layout: {
1019
- type: 'spring',
1020
- visualDuration: 0.15,
1021
- bounce: 0.1,
1022
- },
1023
- }}
1024
- >
1025
- <Comp {...divProps} ref={forwardedRef} className={classNames('rt-ChatbarAttachmentsRow', className)} style={style} role="list" aria-label={divProps['aria-label'] ?? 'Attachments'}>
1020
+ <m.div layout="position" transition={LAYOUT_TRANSITION}>
1021
+ <Comp {...divProps} ref={forwardedRef} className={classNames('rt-ChatbarAttachmentsRow', className)} role="list" aria-label={divProps['aria-label'] ?? 'Attachments'}>
1026
1022
  <ScrollArea className="rt-ChatbarScrollArea" scrollbars="horizontal" size="1">
1027
- <Flex align="center" gap="2" style={{ minWidth: 'fit-content' }}>
1023
+ <Flex align="center" gap="2" minWidth="fit-content">
1028
1024
  {ctx.attachments.map((att) => (
1029
1025
  <Attachment key={att.id} attachment={att} asChild={!!renderAttachment}>
1030
1026
  {renderAttachment?.(att)}
@@ -1044,43 +1040,74 @@ interface AttachmentProps extends React.ComponentPropsWithoutRef<'div'> {
1044
1040
  asChild?: boolean;
1045
1041
  }
1046
1042
 
1043
+ const STATUS_LABEL: Partial<Record<AttachmentStatus, string>> = { uploading: 'uploading', error: 'failed to upload' };
1044
+
1047
1045
  const Attachment = React.forwardRef<HTMLDivElement, AttachmentProps>((props, forwardedRef) => {
1048
- const { attachment, asChild, className, style, children, ...divProps } = props;
1046
+ const { attachment, asChild, className, children, ...divProps } = props;
1049
1047
  const ctx = useChatbarContext();
1050
- const Comp = asChild ? Slot : ('div' as any);
1051
- const isImage = !!attachment.url && attachment.type.startsWith('image/');
1048
+ const Comp = asChild ? Slot : 'div';
1049
+ const isImage = !!attachment.url && isImageLike(attachment.name, attachment.type);
1050
+ const sizeLabel = `${Math.ceil(attachment.size / 1024)} KB`;
1051
+ const statusLabel = attachment.status ? STATUS_LABEL[attachment.status] : undefined;
1052
+ const locked = ctx.disabled || ctx.readOnly;
1053
+
1054
+ const handleRemove = (event: React.MouseEvent<HTMLButtonElement>) => {
1055
+ // Move focus before the tile unmounts so keyboard users are not dropped to <body>
1056
+ const tile = event.currentTarget.closest('[role="listitem"]');
1057
+ const tiles = tile?.parentElement ? Array.from(tile.parentElement.children) : [];
1058
+ const index = tile ? tiles.indexOf(tile) : -1;
1059
+ const neighbour = tiles[index + 1] ?? tiles[index - 1];
1060
+ const nextButton = neighbour?.querySelector<HTMLButtonElement>('.rt-ChatbarAttachmentRemove');
1061
+ if (nextButton) nextButton.focus();
1062
+ else ctx.textareaRef.current?.focus({ preventScroll: true });
1063
+ ctx.removeAttachment(attachment.id);
1064
+ };
1065
+
1052
1066
  return (
1053
- <Comp {...divProps} ref={forwardedRef} className={classNames('rt-ChatbarAttachment', className)} style={style} role="listitem" data-kind={isImage ? 'image' : 'file'} title={attachment.name}>
1067
+ <Comp
1068
+ {...divProps}
1069
+ ref={forwardedRef}
1070
+ className={classNames('rt-ChatbarAttachment', className)}
1071
+ role="listitem"
1072
+ data-kind={isImage ? 'image' : 'file'}
1073
+ data-status={attachment.status}
1074
+ title={attachment.name}
1075
+ >
1054
1076
  {children ?? (
1055
- // <Card size={ctx.size} variant="surface">
1056
1077
  <Flex align="center" gap="2" pr={!isImage ? '6' : undefined}>
1057
1078
  <div className="rt-ChatbarAttachmentPreview" aria-hidden>
1058
1079
  {isImage ? <img className="rt-ChatbarAttachmentImage" src={attachment.url} alt="" /> : <FileTextIcon />}
1059
1080
  </div>
1060
- {!isImage && (
1061
- <Flex direction="column" gap="0" style={{ minWidth: 0 }}>
1062
- <Text size={ctx.size} weight="medium" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
1081
+ {isImage ? (
1082
+ <VisuallyHidden>
1083
+ {attachment.name}, {sizeLabel}
1084
+ {statusLabel ? `, ${statusLabel}` : ''}
1085
+ </VisuallyHidden>
1086
+ ) : (
1087
+ <Flex direction="column" gap="0" minWidth="0">
1088
+ <Text size={ctx.size} weight="medium" truncate>
1063
1089
  {attachment.name}
1064
1090
  </Text>
1065
- <Text size="1" color="gray">
1066
- {Math.ceil(attachment.size / 1024)} KB
1091
+ <Text size="1" color={attachment.status === 'error' ? 'red' : 'gray'}>
1092
+ {sizeLabel}
1093
+ {statusLabel ? ` · ${statusLabel}` : ''}
1067
1094
  </Text>
1068
1095
  </Flex>
1069
1096
  )}
1070
- <IconButton
1071
- className="rt-ChatbarAttachmentRemove"
1072
- aria-label={`Remove ${attachment.name}`}
1073
- size="1"
1074
- // size={ctx.size}
1075
- variant="classic"
1076
- highContrast
1077
- color="gray"
1078
- onClick={() => ctx.setAttachments(ctx.attachments.filter((a) => a.id !== attachment.id))}
1079
- >
1080
- <CloseIcon />
1081
- </IconButton>
1097
+ {!locked && (
1098
+ <IconButton
1099
+ className="rt-ChatbarAttachmentRemove"
1100
+ aria-label={`Remove ${attachment.name}`}
1101
+ size="1"
1102
+ variant="classic"
1103
+ highContrast
1104
+ color="gray"
1105
+ onClick={handleRemove}
1106
+ >
1107
+ <CloseIcon />
1108
+ </IconButton>
1109
+ )}
1082
1110
  </Flex>
1083
- // </Card>
1084
1111
  )}
1085
1112
  </Comp>
1086
1113
  );
@@ -1089,99 +1116,51 @@ Attachment.displayName = 'Chatbar.Attachment';
1089
1116
 
1090
1117
  interface AttachTriggerProps extends React.ComponentPropsWithoutRef<'button'> {
1091
1118
  asChild?: boolean;
1119
+ /** Overrides Root `accept` for files chosen through this trigger */
1092
1120
  accept?: string | string[];
1093
1121
  multiple?: boolean;
1094
1122
  }
1095
1123
 
1096
1124
  const AttachTrigger = React.forwardRef<HTMLButtonElement, AttachTriggerProps>((props, forwardedRef) => {
1097
- const { asChild, accept, multiple, className, style, ...buttonProps } = props;
1125
+ const { asChild, accept, multiple, disabled: disabledProp, onPointerDown, onClick, ...buttonProps } = props;
1098
1126
  const ctx = useChatbarContext();
1099
- const inputRef = React.useRef<HTMLInputElement | null>(null);
1100
- const Comp = asChild ? Slot : ('button' as any);
1101
- // Prefer Chatbar.Root's accept when a local accept is not provided
1102
- const mergedAccept = accept ?? ctx.accept;
1103
- const actualAccept = (Array.isArray(mergedAccept) ? mergedAccept : (mergedAccept?.split(',') ?? [])).join(',');
1104
- React.useEffect(() => {
1105
- const handleWindowFocus = () => {
1106
- // Reset guard when window regains focus after file dialog closes
1107
- ctx.fileDialogOpenRef.current = false;
1108
- };
1109
- window.addEventListener('focus', handleWindowFocus);
1110
- return () => window.removeEventListener('focus', handleWindowFocus);
1111
- }, [ctx.fileDialogOpenRef]);
1127
+ const disabled = disabledProp || ctx.disabled || ctx.readOnly;
1128
+ const Comp = asChild ? Slot : 'button';
1112
1129
  return (
1113
- <>
1114
- <Comp
1115
- {...(buttonProps as any)}
1116
- ref={forwardedRef as any}
1117
- className={classNames('rt-ChatbarAttachTrigger', className)}
1118
- style={style}
1119
- type={buttonProps.type ?? 'button'}
1120
- aria-label={buttonProps['aria-label'] ?? 'Add attachments'}
1121
- onPointerDown={(e: any) => {
1122
- // Set guard before blur occurs (Safari fires blur before click)
1123
- ctx.fileDialogOpenRef.current = true;
1124
- buttonProps.onPointerDown?.(e);
1125
- }}
1126
- onMouseDown={(e: any) => {
1127
- // Fallback for environments without Pointer Events
1128
- ctx.fileDialogOpenRef.current = true;
1129
- buttonProps.onMouseDown?.(e);
1130
- }}
1131
- onClick={(e: any) => {
1132
- // Ensure file input opens reliably by clicking it first
1133
- ctx.fileDialogOpenRef.current = true;
1134
- if (inputRef.current) {
1135
- inputRef.current.click();
1136
- }
1137
- // Then call user's onClick if provided
1138
- buttonProps.onClick?.(e);
1139
- }}
1140
- />
1141
- <input
1142
- ref={inputRef}
1143
- type="file"
1144
- accept={actualAccept}
1145
- multiple={multiple ?? ctx.multiple}
1146
- tabIndex={-1}
1147
- style={{ display: 'none' }}
1148
- onChange={(e) => {
1149
- const files = Array.from(e.currentTarget.files ?? []);
1150
- if (files.length > 0) {
1151
- ctx.appendFiles(files);
1152
- }
1153
- // File dialog closed; allow normal blur handling
1154
- ctx.fileDialogOpenRef.current = false;
1155
- // Reset input value to allow selecting the same file again
1156
- e.currentTarget.value = '';
1157
- }}
1158
- />
1159
- </>
1130
+ <Comp
1131
+ {...buttonProps}
1132
+ ref={forwardedRef}
1133
+ className={classNames('rt-ChatbarAttachTrigger', buttonProps.className)}
1134
+ type={buttonProps.type ?? 'button'}
1135
+ aria-label={buttonProps['aria-label'] ?? 'Add attachments'}
1136
+ disabled={disabled}
1137
+ onPointerDown={(event: React.PointerEvent<HTMLButtonElement>) => {
1138
+ onPointerDown?.(event);
1139
+ if (!disabled && !event.defaultPrevented) ctx.beginFilePickerPress();
1140
+ }}
1141
+ onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
1142
+ onClick?.(event);
1143
+ if (disabled || event.defaultPrevented) return;
1144
+ ctx.openFilePicker({ accept, multiple });
1145
+ }}
1146
+ />
1160
1147
  );
1161
1148
  });
1162
1149
  AttachTrigger.displayName = 'Chatbar.AttachTrigger';
1150
+
1163
1151
  interface RowProps extends Omit<React.ComponentPropsWithoutRef<'div'>, 'children'> {
1164
1152
  asChild?: boolean;
1165
1153
  children?: React.ReactNode;
1166
1154
  }
1167
1155
 
1168
1156
  const Row = React.forwardRef<HTMLDivElement, RowProps>((props, forwardedRef) => {
1169
- const { asChild, children, className, style, ...divProps } = props;
1157
+ const { asChild, children, className, ...divProps } = props;
1170
1158
  const ctx = useChatbarContext();
1171
1159
  if (!ctx.open) return null;
1172
- const Comp = asChild ? Slot : ('div' as any);
1160
+ const Comp = asChild ? Slot : 'div';
1173
1161
  return (
1174
- <m.div
1175
- layout="position"
1176
- transition={{
1177
- layout: {
1178
- type: 'spring',
1179
- visualDuration: 0.15,
1180
- bounce: 0.1,
1181
- },
1182
- }}
1183
- >
1184
- <Comp {...divProps} ref={forwardedRef} className={classNames('rt-ChatbarRow', className)} style={style}>
1162
+ <m.div layout="position" transition={LAYOUT_TRANSITION}>
1163
+ <Comp {...divProps} ref={forwardedRef} className={classNames('rt-ChatbarRow', className)}>
1185
1164
  <Flex align="center" justify="between" width="100%">
1186
1165
  {children}
1187
1166
  </Flex>
@@ -1191,15 +1170,17 @@ const Row = React.forwardRef<HTMLDivElement, RowProps>((props, forwardedRef) =>
1191
1170
  });
1192
1171
  Row.displayName = 'Chatbar.Row';
1193
1172
 
1194
- const RowStart = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>((props, forwardedRef) => {
1195
- const { className, style, ...divProps } = props;
1196
- return <div {...divProps} ref={forwardedRef} className={classNames('rt-ChatbarRowStart', className)} style={style} />;
1173
+ type RowSlotProps = React.ComponentPropsWithoutRef<'div'>;
1174
+
1175
+ const RowStart = React.forwardRef<HTMLDivElement, RowSlotProps>((props, forwardedRef) => {
1176
+ const { className, ...divProps } = props;
1177
+ return <div {...divProps} ref={forwardedRef} className={classNames('rt-ChatbarRowStart', className)} />;
1197
1178
  });
1198
1179
  RowStart.displayName = 'Chatbar.RowStart';
1199
1180
 
1200
- const RowEnd = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>((props, forwardedRef) => {
1201
- const { className, style, ...divProps } = props;
1202
- return <div {...divProps} ref={forwardedRef} className={classNames('rt-ChatbarRowEnd', className)} style={style} />;
1181
+ const RowEnd = React.forwardRef<HTMLDivElement, RowSlotProps>((props, forwardedRef) => {
1182
+ const { className, ...divProps } = props;
1183
+ return <div {...divProps} ref={forwardedRef} className={classNames('rt-ChatbarRowEnd', className)} />;
1203
1184
  });
1204
1185
  RowEnd.displayName = 'Chatbar.RowEnd';
1205
1186
 
@@ -1208,59 +1189,65 @@ type SendProps = Omit<IconButtonProps, 'aria-label' | 'aria-labelledby'> & {
1208
1189
  'aria-label'?: string;
1209
1190
  'aria-labelledby'?: string;
1210
1191
  asChild?: boolean;
1192
+ /** Clear message and attachments after sending. Defaults to Root `clearOnSubmit`. */
1211
1193
  clearOnSend?: boolean;
1212
1194
  };
1213
1195
 
1214
1196
  const Send = React.forwardRef<HTMLButtonElement, SendProps>((props, forwardedRef) => {
1215
- const { asChild, clearOnSend = true, disabled, children, className, style, size: sizeProp, variant: variantProp, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, ...buttonProps } = props;
1197
+ const {
1198
+ asChild,
1199
+ clearOnSend,
1200
+ disabled,
1201
+ children,
1202
+ style,
1203
+ className,
1204
+ size: sizeProp,
1205
+ variant: variantProp,
1206
+ tabIndex,
1207
+ onClick,
1208
+ 'aria-label': ariaLabel,
1209
+ 'aria-labelledby': ariaLabelledby,
1210
+ ...buttonProps
1211
+ } = props;
1216
1212
  const ctx = useChatbarContext();
1213
+ const value = React.useContext(ChatbarValueContext);
1217
1214
 
1218
- const trimmed = ctx.value.trim();
1219
- const hasContent = trimmed.length > 0 || ctx.attachments.length > 0;
1220
- const visible = ctx.sendMode === 'always' || (ctx.sendMode === 'whenDirty' && hasContent);
1221
1215
  if (ctx.sendMode === 'never') return null;
1222
1216
 
1217
+ const hasContent = hasText(value) || ctx.attachments.length > 0;
1218
+ const visible = ctx.sendMode === 'always' || hasContent;
1219
+ const inactive = disabled || ctx.disabled || ctx.readOnly || !hasContent;
1220
+
1221
+ const labelProps: { 'aria-label': string } | { 'aria-labelledby': string } = ariaLabelledby
1222
+ ? { 'aria-labelledby': ariaLabelledby }
1223
+ : { 'aria-label': ariaLabel ?? 'Send' };
1224
+
1223
1225
  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
1224
- if (ctx.disabled || ctx.readOnly) return;
1225
- ctx.onSubmit?.({ value: ctx.value, attachments: ctx.attachments });
1226
- if (clearOnSend) {
1227
- if (!ctx.isValueControlled) ctx.setValue('');
1228
- if (ctx.clearOnSubmit) ctx.setAttachments([]);
1229
- }
1230
- buttonProps.onClick?.(event);
1226
+ onClick?.(event);
1227
+ if (event.defaultPrevented || inactive) return;
1228
+ const clear = clearOnSend ?? ctx.clearOnSubmit;
1229
+ // Clearing hides and disables this button; keep keyboard focus in the composer
1230
+ if (ctx.submit(clear) && clear) ctx.textareaRef.current?.focus({ preventScroll: true });
1231
1231
  };
1232
1232
 
1233
1233
  return (
1234
1234
  <IconButton
1235
- {...(buttonProps as any)}
1236
- ref={forwardedRef as any}
1235
+ {...buttonProps}
1236
+ ref={forwardedRef}
1237
1237
  size={sizeProp ?? ctx.size}
1238
1238
  variant={variantProp ?? (ctx.open ? 'solid' : 'ghost')}
1239
- disabled={disabled || ctx.disabled || ctx.readOnly}
1239
+ disabled={inactive}
1240
1240
  className={classNames('rt-ChatbarSend', className)}
1241
- style={{
1242
- opacity: visible ? 1 : 0,
1243
- pointerEvents: visible ? 'auto' : 'none',
1244
- ...style,
1245
- }}
1241
+ style={{ opacity: visible ? 1 : 0, pointerEvents: visible ? undefined : 'none', ...style }}
1242
+ // Hidden Send must not be reachable by keyboard or announced
1243
+ aria-hidden={visible ? undefined : true}
1244
+ tabIndex={visible ? tabIndex : -1}
1246
1245
  asChild={asChild}
1247
1246
  onClick={handleClick}
1248
- aria-label={ariaLabel ?? (ariaLabelledby ? undefined : 'Send')}
1249
- aria-labelledby={ariaLabelledby}
1247
+ {...labelProps}
1250
1248
  >
1251
1249
  {children ?? (
1252
- <svg
1253
- xmlns="http://www.w3.org/2000/svg"
1254
- width="24"
1255
- height="24"
1256
- viewBox="0 0 24 24"
1257
- fill="none"
1258
- stroke="currentColor"
1259
- strokeWidth="2"
1260
- strokeLinecap="round"
1261
- strokeLinejoin="round"
1262
- className="lucide lucide-arrow-right-icon lucide-arrow-right"
1263
- >
1250
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
1264
1251
  <path d="M5 12h14" />
1265
1252
  <path d="m12 5 7 7-7 7" />
1266
1253
  </svg>
@@ -1271,4 +1258,21 @@ const Send = React.forwardRef<HTMLButtonElement, SendProps>((props, forwardedRef
1271
1258
  Send.displayName = 'Chatbar.Send';
1272
1259
 
1273
1260
  export { Root, Textarea, InlineStart, InlineEnd, AttachmentsRow, Attachment, AttachTrigger, Row, RowStart, RowEnd, Send };
1274
- export type { RootProps as ChatbarRootProps, TextareaProps as ChatbarTextareaProps, RowProps as ChatbarRowProps, SendProps as ChatbarSendProps, ChatbarAttachment };
1261
+ export type {
1262
+ RootProps as ChatbarRootProps,
1263
+ TextareaProps as ChatbarTextareaProps,
1264
+ InlineSlotProps as ChatbarInlineSlotProps,
1265
+ AttachmentsRowProps as ChatbarAttachmentsRowProps,
1266
+ AttachmentProps as ChatbarAttachmentProps,
1267
+ AttachTriggerProps as ChatbarAttachTriggerProps,
1268
+ RowProps as ChatbarRowProps,
1269
+ RowSlotProps as ChatbarRowSlotProps,
1270
+ SendProps as ChatbarSendProps,
1271
+ ChatbarAttachment,
1272
+ ChatbarAttachmentRejection,
1273
+ ChatbarRejectReason,
1274
+ ChatbarSubmitPayload,
1275
+ AttachmentStatus as ChatbarAttachmentStatus,
1276
+ ExpandOn as ChatbarExpandOn,
1277
+ SendMode as ChatbarSendMode,
1278
+ };