@kushagradhawan/kookie-ui 0.1.32 → 0.1.34

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 (120) hide show
  1. package/components.css +937 -458
  2. package/dist/cjs/components/_internal/base-button.d.ts.map +1 -1
  3. package/dist/cjs/components/_internal/base-button.js +1 -1
  4. package/dist/cjs/components/_internal/base-button.js.map +3 -3
  5. package/dist/cjs/components/chatbar.d.ts +202 -0
  6. package/dist/cjs/components/chatbar.d.ts.map +1 -0
  7. package/dist/cjs/components/chatbar.js +2 -0
  8. package/dist/cjs/components/chatbar.js.map +7 -0
  9. package/dist/cjs/components/icon-button.d.ts.map +1 -1
  10. package/dist/cjs/components/icon-button.js +2 -2
  11. package/dist/cjs/components/icon-button.js.map +3 -3
  12. package/dist/cjs/components/icons.d.ts +6 -1
  13. package/dist/cjs/components/icons.d.ts.map +1 -1
  14. package/dist/cjs/components/icons.js +1 -1
  15. package/dist/cjs/components/icons.js.map +3 -3
  16. package/dist/cjs/components/index.d.ts +3 -0
  17. package/dist/cjs/components/index.d.ts.map +1 -1
  18. package/dist/cjs/components/index.js +1 -1
  19. package/dist/cjs/components/index.js.map +3 -3
  20. package/dist/cjs/components/popover.d.ts +13 -1
  21. package/dist/cjs/components/popover.d.ts.map +1 -1
  22. package/dist/cjs/components/popover.js +1 -1
  23. package/dist/cjs/components/popover.js.map +3 -3
  24. package/dist/cjs/components/sheet.d.ts +82 -0
  25. package/dist/cjs/components/sheet.d.ts.map +1 -0
  26. package/dist/cjs/components/sheet.js +2 -0
  27. package/dist/cjs/components/sheet.js.map +7 -0
  28. package/dist/cjs/components/shell.d.ts +180 -0
  29. package/dist/cjs/components/shell.d.ts.map +1 -0
  30. package/dist/cjs/components/shell.js +2 -0
  31. package/dist/cjs/components/shell.js.map +7 -0
  32. package/dist/cjs/components/sidebar.d.ts +4 -33
  33. package/dist/cjs/components/sidebar.d.ts.map +1 -1
  34. package/dist/cjs/components/sidebar.js +1 -1
  35. package/dist/cjs/components/sidebar.js.map +3 -3
  36. package/dist/cjs/components/skeleton.d.ts.map +1 -1
  37. package/dist/cjs/components/skeleton.js +1 -1
  38. package/dist/cjs/components/skeleton.js.map +2 -2
  39. package/dist/cjs/helpers/inert.d.ts +1 -1
  40. package/dist/cjs/helpers/inert.d.ts.map +1 -1
  41. package/dist/cjs/helpers/inert.js +1 -1
  42. package/dist/cjs/helpers/inert.js.map +2 -2
  43. package/dist/esm/components/_internal/base-button.d.ts.map +1 -1
  44. package/dist/esm/components/_internal/base-button.js +1 -1
  45. package/dist/esm/components/_internal/base-button.js.map +3 -3
  46. package/dist/esm/components/chatbar.d.ts +202 -0
  47. package/dist/esm/components/chatbar.d.ts.map +1 -0
  48. package/dist/esm/components/chatbar.js +2 -0
  49. package/dist/esm/components/chatbar.js.map +7 -0
  50. package/dist/esm/components/icon-button.d.ts.map +1 -1
  51. package/dist/esm/components/icon-button.js +2 -2
  52. package/dist/esm/components/icon-button.js.map +3 -3
  53. package/dist/esm/components/icons.d.ts +6 -1
  54. package/dist/esm/components/icons.d.ts.map +1 -1
  55. package/dist/esm/components/icons.js +1 -1
  56. package/dist/esm/components/icons.js.map +3 -3
  57. package/dist/esm/components/index.d.ts +3 -0
  58. package/dist/esm/components/index.d.ts.map +1 -1
  59. package/dist/esm/components/index.js +1 -1
  60. package/dist/esm/components/index.js.map +3 -3
  61. package/dist/esm/components/popover.d.ts +13 -1
  62. package/dist/esm/components/popover.d.ts.map +1 -1
  63. package/dist/esm/components/popover.js +1 -1
  64. package/dist/esm/components/popover.js.map +3 -3
  65. package/dist/esm/components/sheet.d.ts +82 -0
  66. package/dist/esm/components/sheet.d.ts.map +1 -0
  67. package/dist/esm/components/sheet.js +2 -0
  68. package/dist/esm/components/sheet.js.map +7 -0
  69. package/dist/esm/components/shell.d.ts +180 -0
  70. package/dist/esm/components/shell.d.ts.map +1 -0
  71. package/dist/esm/components/shell.js +2 -0
  72. package/dist/esm/components/shell.js.map +7 -0
  73. package/dist/esm/components/sidebar.d.ts +4 -33
  74. package/dist/esm/components/sidebar.d.ts.map +1 -1
  75. package/dist/esm/components/sidebar.js +1 -1
  76. package/dist/esm/components/sidebar.js.map +3 -3
  77. package/dist/esm/components/skeleton.d.ts.map +1 -1
  78. package/dist/esm/components/skeleton.js.map +2 -2
  79. package/dist/esm/helpers/inert.d.ts +1 -1
  80. package/dist/esm/helpers/inert.d.ts.map +1 -1
  81. package/dist/esm/helpers/inert.js +1 -1
  82. package/dist/esm/helpers/inert.js.map +2 -2
  83. package/package.json +2 -1
  84. package/src/components/_internal/base-button.tsx +8 -0
  85. package/src/components/_internal/base-card.css +18 -18
  86. package/src/components/_internal/base-dialog.css +11 -49
  87. package/src/components/_internal/base-menu.css +2 -2
  88. package/src/components/_internal/base-sidebar-menu.css +3 -3
  89. package/src/components/accordion.css +6 -6
  90. package/src/components/animations.css +65 -81
  91. package/src/components/callout.css +3 -3
  92. package/src/components/chatbar.css +214 -0
  93. package/src/components/chatbar.tsx +1181 -0
  94. package/src/components/icon-button.tsx +11 -0
  95. package/src/components/icons.tsx +97 -2
  96. package/src/components/image.css +3 -3
  97. package/src/components/index.css +3 -0
  98. package/src/components/index.tsx +3 -0
  99. package/src/components/popover.css +53 -8
  100. package/src/components/popover.tsx +180 -2
  101. package/src/components/scroll-area.css +3 -3
  102. package/src/components/segmented-control.css +3 -3
  103. package/src/components/sheet.css +90 -0
  104. package/src/components/sheet.tsx +247 -0
  105. package/src/components/shell.css +137 -0
  106. package/src/components/shell.tsx +1032 -0
  107. package/src/components/sidebar.css +55 -268
  108. package/src/components/sidebar.tsx +40 -262
  109. package/src/components/skeleton.tsx +1 -2
  110. package/src/components/text-area.css +6 -5
  111. package/src/components/tooltip.css +2 -2
  112. package/src/helpers/inert.ts +3 -3
  113. package/src/styles/tokens/constants.css +6 -3
  114. package/src/styles/tokens/index.css +1 -0
  115. package/src/styles/tokens/radius.css +7 -2
  116. package/src/styles/tokens/space.css +6 -0
  117. package/src/styles/tokens/transition.css +91 -46
  118. package/styles.css +998 -496
  119. package/tokens/base.css +57 -35
  120. package/tokens.css +61 -38
@@ -0,0 +1,1181 @@
1
+ import * as React from 'react';
2
+ import classNames from 'classnames';
3
+
4
+ import { IconButton, type IconButtonProps } from './icon-button.js';
5
+ import { CloseIcon, FileTextIcon } from './icons.js';
6
+ import { Flex } from './flex.js';
7
+
8
+ import { ScrollArea } from './scroll-area.js';
9
+ import { Slot } from './slot.js';
10
+ import { Card } from './card.js';
11
+ import { Text } from './text.js';
12
+ import { useDropzone } from 'react-dropzone';
13
+ import type { ComponentPropsWithout, RemovedProps } from '../helpers/component-props.js';
14
+
15
+ // Avoid SSR warnings by using an isomorphic layout effect
16
+ const useIsomorphicLayoutEffect =
17
+ typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;
18
+
19
+ type ExpandOn = 'none' | 'focus' | 'overflow' | 'both';
20
+ type SendMode = 'always' | 'whenDirty' | 'never';
21
+
22
+ // Attachments
23
+ /** Status flag for attachment lifecycle. */
24
+ type AttachmentStatus = 'idle' | 'uploading' | 'error' | 'done';
25
+ /**
26
+ * Attachment data model used by Chatbar.
27
+ * - `url` is an object URL used for image previews and is revoked on removal.
28
+ */
29
+ interface ChatbarAttachment {
30
+ id: string;
31
+ name: string;
32
+ size: number;
33
+ type: string;
34
+ url?: string;
35
+ status?: AttachmentStatus;
36
+ progress?: number;
37
+ meta?: Record<string, unknown>;
38
+ }
39
+
40
+ interface ChatbarContextValue {
41
+ open: boolean;
42
+ setOpen(next: boolean): void;
43
+ isOpenControlled: boolean;
44
+
45
+ value: string;
46
+ setValue(next: string): void;
47
+ isValueControlled: boolean;
48
+
49
+ size: '1' | '2' | '3';
50
+ expandOn: ExpandOn;
51
+ minLines: number;
52
+ maxLines: number;
53
+ sendMode: SendMode;
54
+ disabled?: boolean;
55
+ readOnly?: boolean;
56
+
57
+ // Submit returns both message and attachments
58
+ onSubmit?: (payload: { value: string; attachments: ChatbarAttachment[] }) => void;
59
+
60
+ rootRef: React.RefObject<HTMLDivElement | null>;
61
+ textareaRef: React.RefObject<HTMLTextAreaElement | null>;
62
+
63
+ // Attachments state
64
+ attachments: ChatbarAttachment[];
65
+ setAttachments(next: ChatbarAttachment[]): void;
66
+ isAttachmentsControlled: boolean;
67
+
68
+ // Config
69
+ accept?: string | string[];
70
+ multiple: boolean;
71
+ maxAttachments?: number;
72
+ maxFileSize?: number;
73
+ paste: boolean;
74
+ pasteAccept?: string | string[];
75
+ clearOnSubmit: boolean;
76
+
77
+ // Dropzone
78
+ dropzone: boolean;
79
+
80
+ // Events
81
+ onAttachmentReject?: (rejections: { file: File; reason: 'type' | 'size' | 'count' }[]) => void;
82
+
83
+ // Helpers
84
+ appendFiles(files: File[]): void;
85
+ appendFilesFromPaste(files: File[]): void;
86
+ }
87
+
88
+ const ChatbarContext = React.createContext<ChatbarContextValue | null>(null);
89
+ const useChatbarContext = () => {
90
+ const ctx = React.useContext(ChatbarContext);
91
+ if (!ctx) throw new Error('Chatbar context not found. Wrap parts in <Chatbar.Root>.');
92
+ return ctx;
93
+ };
94
+
95
+ /**
96
+ * Chatbar container and state provider.
97
+ *
98
+ * Value & Open
99
+ * - Supports controlled/uncontrolled `value` and `open`.
100
+ *
101
+ * Attachments
102
+ * - Controlled/uncontrolled attachments with client-side filtering.
103
+ * - Filters by `accept`/`pasteAccept`, `maxAttachments`, and `maxFileSize`.
104
+ * - Rejections are reported via `onAttachmentReject`.
105
+ *
106
+ * Submit
107
+ * - `onSubmit({ value, attachments })` emits both message and attachments.
108
+ * - `clearOnSubmit` clears message and attachments by default.
109
+ */
110
+ interface ChatbarRootBaseProps {
111
+ value?: string;
112
+ defaultValue?: string;
113
+ onValueChange?: (value: string) => void;
114
+
115
+ open?: boolean;
116
+ defaultOpen?: boolean;
117
+ onOpenChange?: (open: boolean) => void;
118
+
119
+ expandOn?: ExpandOn;
120
+
121
+ /** Minimum number of lines in compact state (default: 1) */
122
+ minLines?: number;
123
+ /** Maximum number of lines before scrolling (default: 6) */
124
+ maxLines?: number;
125
+
126
+ sendMode?: SendMode;
127
+
128
+ disabled?: boolean;
129
+ readOnly?: boolean;
130
+
131
+ /** Combined submit payload */
132
+ onSubmit?: (payload: { value: string; attachments: ChatbarAttachment[] }) => void;
133
+
134
+ size?: '1' | '2' | '3';
135
+ variant?: 'surface' | 'outline' | 'classic' | 'ghost' | 'soft';
136
+
137
+ width?: React.CSSProperties['width'];
138
+ maxWidth?: React.CSSProperties['maxWidth'];
139
+ asChild?: boolean;
140
+
141
+ // Attachments API
142
+ attachments?: ChatbarAttachment[];
143
+ defaultAttachments?: ChatbarAttachment[];
144
+ onAttachmentsChange?: (attachments: ChatbarAttachment[]) => void;
145
+ accept?: string | string[];
146
+ multiple?: boolean;
147
+ maxAttachments?: number;
148
+ maxFileSize?: number;
149
+ paste?: boolean;
150
+ pasteAccept?: string | string[];
151
+ clearOnSubmit?: boolean;
152
+ onAttachmentReject?: (rejections: { file: File; reason: 'type' | 'size' | 'count' }[]) => void;
153
+
154
+ /**
155
+ * Enables drag-and-drop file uploads when true.
156
+ *
157
+ * When enabled:
158
+ * - Files can be dropped anywhere on the chatbar
159
+ * - Same validation rules apply (accept, maxFileSize, maxAttachments)
160
+ * - Visual feedback shows during drag operations
161
+ * - Rejected files trigger onAttachmentReject
162
+ *
163
+ * @default true
164
+ */
165
+ dropzone?: boolean;
166
+ }
167
+
168
+ type RootElement = React.ElementRef<'div'>;
169
+ /**
170
+ * Chatbar container and state provider.
171
+ *
172
+ * Behavior
173
+ * - Supports controlled and uncontrolled `value` and `open` states via props.
174
+ * - Provides context to subcomponents like `Textarea`, `Row`, and `Send`.
175
+ * - Exposes `data-state`, `data-disabled`, and `data-readonly` attributes for styling.
176
+ * - Sets `aria-expanded` to reflect open/closed state for assistive technologies.
177
+ *
178
+ * Attachments
179
+ * - Controlled/uncontrolled attachments with client-side filtering.
180
+ * - Filters by `accept`/`pasteAccept`, `maxAttachments`, and `maxFileSize`.
181
+ * - Rejections are reported via `onAttachmentReject`.
182
+ * - Paste-to-attach: when `paste` is enabled, pasting files adds attachments.
183
+ *
184
+ * Dropzone
185
+ * - When `dropzone` is true, enables drag-and-drop file uploads.
186
+ * - Files are validated using the same rules as paste and file picker.
187
+ * - Visual feedback via `data-drop-active` attribute during drag operations.
188
+ * - Rejected files trigger `onAttachmentReject` with appropriate reasons.
189
+ *
190
+ * Submit
191
+ * - `onSubmit` receives both message text and attachments array.
192
+ * - `clearOnSubmit` controls whether attachments are cleared after submission.
193
+ *
194
+ * Accessibility
195
+ * - Consumers should label the `Textarea` via `aria-label`/`aria-labelledby`.
196
+ * - `aria-expanded` on the root reflects the disclosure state of the input area.
197
+ * - Dropzone provides proper ARIA attributes for drag and drop operations.
198
+ */
199
+ interface RootProps
200
+ extends ComponentPropsWithout<'div', RemovedProps | 'onSubmit'>,
201
+ ChatbarRootBaseProps {}
202
+
203
+ const Root = React.forwardRef<RootElement, RootProps>((props, forwardedRef) => {
204
+ const {
205
+ className,
206
+ style,
207
+ children,
208
+ value: valueProp,
209
+ defaultValue = '',
210
+ onValueChange: onValueChangeProp,
211
+ open: openProp,
212
+ defaultOpen = false,
213
+ onOpenChange: onOpenChangeProp,
214
+ expandOn = 'both',
215
+ minLines = 1,
216
+ maxLines = 6,
217
+ sendMode = 'whenDirty',
218
+ disabled,
219
+ readOnly,
220
+ onSubmit,
221
+ size = '2',
222
+ variant,
223
+ width,
224
+ maxWidth,
225
+ asChild,
226
+ attachments: attachmentsProp,
227
+ defaultAttachments = [],
228
+ onAttachmentsChange,
229
+ accept,
230
+ multiple = true,
231
+ maxAttachments,
232
+ maxFileSize,
233
+ paste = true,
234
+ pasteAccept,
235
+ clearOnSubmit = true,
236
+ onAttachmentReject,
237
+ dropzone = true,
238
+ ...divProps
239
+ } = props;
240
+
241
+ const isValueControlled = valueProp != null;
242
+ const [valueUncontrolled, setValueUncontrolled] = React.useState<string>(defaultValue);
243
+ const value = isValueControlled ? (valueProp as string) : valueUncontrolled;
244
+
245
+ const isOpenControlled = openProp != null;
246
+ const [openUncontrolled, setOpenUncontrolled] = React.useState<boolean>(defaultOpen);
247
+ const open = isOpenControlled ? (openProp as boolean) : openUncontrolled;
248
+
249
+ const rootRef = React.useRef<HTMLDivElement>(null);
250
+ const textareaRef = React.useRef<HTMLTextAreaElement>(null);
251
+
252
+ // Attachments state
253
+ const isAttachmentsControlled = attachmentsProp != null;
254
+ const [attachmentsUncontrolled, setAttachmentsUncontrolled] =
255
+ React.useState<ChatbarAttachment[]>(defaultAttachments);
256
+ const attachments = isAttachmentsControlled
257
+ ? (attachmentsProp as ChatbarAttachment[])
258
+ : attachmentsUncontrolled;
259
+
260
+ // Track generated object URLs for cleanup
261
+ const generatedUrlSetRef = React.useRef<Set<string>>(new Set());
262
+
263
+ const toArray = (val: string | string[] | undefined) =>
264
+ Array.isArray(val) ? val : typeof val === 'string' ? val.split(',').map((s) => s.trim()) : [];
265
+
266
+ const accepts = toArray(accept);
267
+ const pasteAccepts = toArray(pasteAccept).length > 0 ? toArray(pasteAccept) : accepts;
268
+
269
+ const matchesAccept = (file: File, patterns: string[]) => {
270
+ if (patterns.length === 0) return true;
271
+ const mime = file.type.toLowerCase();
272
+ const name = file.name.toLowerCase();
273
+ for (const patRaw of patterns) {
274
+ const pat = patRaw.toLowerCase();
275
+ if (pat.includes('/')) {
276
+ // MIME pattern
277
+ const [type, subtype] = pat.split('/');
278
+ const [fmType, fmSubtype] = mime.split('/');
279
+ if (type === '*' || (type === fmType && (subtype === '*' || subtype === fmSubtype)))
280
+ return true;
281
+ } else if (pat.startsWith('.')) {
282
+ if (name.endsWith(pat)) return true;
283
+ }
284
+ }
285
+ return false;
286
+ };
287
+
288
+ /**
289
+ * Maps File objects to attachments with validation and preview URL generation.
290
+ */
291
+ const mapFilesToAttachments = (
292
+ files: File[],
293
+ ): {
294
+ accepted: ChatbarAttachment[];
295
+ rejected: { file: File; reason: 'type' | 'size' | 'count' }[];
296
+ } => {
297
+ const next: ChatbarAttachment[] = [];
298
+ const rejected: { file: File; reason: 'type' | 'size' | 'count' }[] = [];
299
+
300
+ const remainingSlots =
301
+ typeof maxAttachments === 'number'
302
+ ? Math.max(maxAttachments - attachments.length, 0)
303
+ : Infinity;
304
+
305
+ for (const file of files) {
306
+ if (next.length >= remainingSlots) {
307
+ rejected.push({ file, reason: 'count' });
308
+ continue;
309
+ }
310
+ if (typeof maxFileSize === 'number' && file.size > maxFileSize) {
311
+ rejected.push({ file, reason: 'size' });
312
+ continue;
313
+ }
314
+ if (!matchesAccept(file, accepts)) {
315
+ rejected.push({ file, reason: 'type' });
316
+ continue;
317
+ }
318
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
319
+ const looksLikeImageByExt = /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(file.name);
320
+ const isImageType = (file.type || '').toLowerCase().startsWith('image/');
321
+ const url = isImageType || looksLikeImageByExt ? URL.createObjectURL(file) : undefined;
322
+ if (url) generatedUrlSetRef.current.add(url);
323
+ next.push({ id, name: file.name, size: file.size, type: file.type, url, status: 'idle' });
324
+ }
325
+ return { accepted: next, rejected };
326
+ };
327
+
328
+ const appendFiles = (files: File[]) => {
329
+ const { accepted, rejected } = mapFilesToAttachments(files);
330
+ if (accepted.length > 0) {
331
+ const merged = attachments.concat(accepted);
332
+ if (!isAttachmentsControlled) setAttachmentsUncontrolled(merged);
333
+ onAttachmentsChange?.(merged);
334
+ }
335
+ if (rejected.length > 0) onAttachmentReject?.(rejected);
336
+ };
337
+
338
+ const appendFilesFromPaste = (files: File[]) => {
339
+ // Use pasteAccepts for type filtering
340
+ const matches = (file: File) => {
341
+ if (pasteAccepts.length === 0) return matchesAccept(file, accepts);
342
+ return matchesAccept(file, pasteAccepts);
343
+ };
344
+ const acceptedFiles: File[] = [];
345
+ const rejected: { file: File; reason: 'type' | 'size' | 'count' }[] = [];
346
+
347
+ // Enforce maxAttachments and maxFileSize
348
+ const remainingSlots =
349
+ typeof maxAttachments === 'number'
350
+ ? Math.max(maxAttachments - attachments.length, 0)
351
+ : Infinity;
352
+ for (const file of files) {
353
+ if (acceptedFiles.length >= remainingSlots) {
354
+ rejected.push({ file, reason: 'count' });
355
+ continue;
356
+ }
357
+ if (typeof maxFileSize === 'number' && file.size > maxFileSize) {
358
+ rejected.push({ file, reason: 'size' });
359
+ continue;
360
+ }
361
+ if (!matches(file)) {
362
+ rejected.push({ file, reason: 'type' });
363
+ continue;
364
+ }
365
+ acceptedFiles.push(file);
366
+ }
367
+ if (acceptedFiles.length > 0) appendFiles(acceptedFiles);
368
+ if (rejected.length > 0) onAttachmentReject?.(rejected);
369
+ };
370
+
371
+ // Revoke object URLs that are no longer referenced by current attachments
372
+ React.useEffect(() => {
373
+ const currentUrls = new Set(attachments.map((a) => a.url).filter(Boolean) as string[]);
374
+ for (const url of Array.from(generatedUrlSetRef.current)) {
375
+ if (!currentUrls.has(url)) {
376
+ URL.revokeObjectURL(url);
377
+ generatedUrlSetRef.current.delete(url);
378
+ }
379
+ }
380
+ }, [attachments]);
381
+
382
+ // Revoke any remaining generated URLs on unmount
383
+ React.useEffect(() => {
384
+ return () => {
385
+ for (const url of Array.from(generatedUrlSetRef.current)) {
386
+ URL.revokeObjectURL(url);
387
+ }
388
+ generatedUrlSetRef.current.clear();
389
+ };
390
+ }, []);
391
+
392
+ const Comp = asChild ? Slot : ('div' as any);
393
+
394
+ const handleBlurCapture = React.useCallback(
395
+ (event: React.FocusEvent) => {
396
+ const nextTarget = event.relatedTarget as Node | null;
397
+ const rootEl = rootRef.current;
398
+ if (!rootEl) return;
399
+ // If focus remains within root, ignore
400
+ if (nextTarget && rootEl.contains(nextTarget)) return;
401
+ // Collapse when leaving the root if the value is empty
402
+ if ((value?.trim?.() ?? '').length === 0) {
403
+ if (!isOpenControlled) setOpenUncontrolled(false);
404
+ onOpenChangeProp?.(false);
405
+ }
406
+ },
407
+ [isOpenControlled, onOpenChangeProp, value],
408
+ );
409
+
410
+ // Dropzone functionality
411
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
412
+ onDrop: (acceptedFiles, rejectedFiles) => {
413
+ if (acceptedFiles.length > 0) {
414
+ appendFiles(acceptedFiles);
415
+ }
416
+ if (rejectedFiles.length > 0 && onAttachmentReject) {
417
+ const rejections = rejectedFiles.map(({ file, errors }) => {
418
+ const reason =
419
+ errors[0]?.code === 'file-too-large'
420
+ ? 'size'
421
+ : errors[0]?.code === 'file-invalid-type'
422
+ ? 'type'
423
+ : 'count';
424
+ return { file, reason: reason as 'type' | 'size' | 'count' };
425
+ });
426
+ onAttachmentReject(rejections);
427
+ }
428
+ },
429
+ accept:
430
+ accepts.length > 0
431
+ ? accepts.reduce(
432
+ (acc, pattern) => {
433
+ if (pattern.includes('/')) {
434
+ // MIME type pattern
435
+ acc[pattern] = [];
436
+ } else if (pattern.startsWith('.')) {
437
+ // File extension pattern
438
+ acc[pattern] = [];
439
+ }
440
+ return acc;
441
+ },
442
+ {} as Record<string, string[]>,
443
+ )
444
+ : undefined,
445
+ multiple,
446
+ maxSize: maxFileSize,
447
+ noClick: true,
448
+ noKeyboard: true,
449
+ disabled: !dropzone || disabled,
450
+ });
451
+
452
+ return (
453
+ <ChatbarContext.Provider
454
+ value={{
455
+ open,
456
+ setOpen: (next) => {
457
+ if (!isOpenControlled) setOpenUncontrolled(next);
458
+ onOpenChangeProp?.(next);
459
+ },
460
+ isOpenControlled,
461
+ value,
462
+ setValue: (next) => {
463
+ if (!isValueControlled) setValueUncontrolled(next);
464
+ onValueChangeProp?.(next);
465
+ },
466
+ isValueControlled,
467
+ size,
468
+ expandOn,
469
+ minLines,
470
+ maxLines,
471
+ sendMode,
472
+ disabled,
473
+ readOnly,
474
+ onSubmit,
475
+ rootRef,
476
+ textareaRef,
477
+ attachments,
478
+ setAttachments: (next) => {
479
+ if (!isAttachmentsControlled) setAttachmentsUncontrolled(next);
480
+ onAttachmentsChange?.(next);
481
+ },
482
+ isAttachmentsControlled,
483
+ accept,
484
+ multiple,
485
+ maxAttachments,
486
+ maxFileSize,
487
+ paste,
488
+ pasteAccept,
489
+ clearOnSubmit,
490
+ onAttachmentReject,
491
+ dropzone,
492
+ appendFiles,
493
+ appendFilesFromPaste,
494
+ }}
495
+ >
496
+ <Comp
497
+ {...divProps}
498
+ ref={(node: HTMLDivElement) => {
499
+ if (typeof forwardedRef === 'function') forwardedRef(node);
500
+ else if (forwardedRef)
501
+ (forwardedRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
502
+ (rootRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
503
+ }}
504
+ className={classNames('rt-ChatbarRoot', `rt-r-size-${size}`, className)}
505
+ style={{ position: 'relative', width, maxWidth, ...style }}
506
+ data-state={open ? 'open' : 'closed'}
507
+ data-disabled={disabled ? '' : undefined}
508
+ data-readonly={readOnly ? '' : undefined}
509
+ data-drop-active={dropzone && isDragActive ? '' : undefined}
510
+ aria-expanded={open}
511
+ onBlurCapture={handleBlurCapture}
512
+ >
513
+ {dropzone && <input {...getInputProps()} />}
514
+ <div {...(dropzone ? getRootProps() : {})} style={{ width: '100%', height: '100%' }}>
515
+ <Card
516
+ className="rt-ChatbarCard"
517
+ size={Math.min(3, Number(size) + 1).toString() as '1' | '2' | '3'}
518
+ variant={variant as any}
519
+ style={{ position: 'relative' }}
520
+ >
521
+ <div className="rt-ChatbarGrid">{children}</div>
522
+ {dropzone && isDragActive && (
523
+ <div className="rt-ChatbarDropOverlay">
524
+ <div className="rt-ChatbarDropContent">
525
+ <Text color="gray" size={size} weight="medium">
526
+ Drop files here to attach
527
+ </Text>
528
+ </div>
529
+ </div>
530
+ )}
531
+ </Card>
532
+ </div>
533
+ </Comp>
534
+ </ChatbarContext.Provider>
535
+ );
536
+ });
537
+ Root.displayName = 'Chatbar.Root';
538
+
539
+ /**
540
+ * Multi-line text input for Chatbar.
541
+ * - Uses onChange to control value and avoid duplicate updates.
542
+ * - Auto-resizes between minLines and maxLines.
543
+ * - Expands on focus/overflow per `expandOn`.
544
+ * - Paste-to-attach: when `paste` is enabled on Root, pasting files adds attachments.
545
+ * - Provide `aria-label` or `aria-labelledby` for an accessible name.
546
+ */
547
+ interface TextareaProps extends Omit<React.ComponentPropsWithoutRef<'textarea'>, 'size'> {
548
+ asChild?: boolean;
549
+ /**
550
+ * Handler for paste events. This is forwarded to the underlying <textarea>.
551
+ */
552
+ onPaste?: React.ClipboardEventHandler<HTMLTextAreaElement>;
553
+ /**
554
+ * When true, pressing Enter submits via onSend (Shift+Enter inserts newline).
555
+ * Defaults to false.
556
+ */
557
+ submitOnEnter?: boolean;
558
+ }
559
+
560
+ /**
561
+ * Chatbar multi-line text input.
562
+ *
563
+ * Behavior
564
+ * - Controls the Chatbar value via React onChange. We intentionally do not
565
+ * update state in onInput to avoid duplicate updates per keystroke.
566
+ * - Auto-resizes between minLines and maxLines using layout measurements.
567
+ * - When expandOn is `overflow` or `both`, the Chatbar opens as soon as the
568
+ * content exceeds the compact height.
569
+ * - Height recalculations occur on change, paste, and whenever `value` or `open`
570
+ * changes via an isomorphic layout effect to avoid SSR warnings.
571
+ *
572
+ * Accessibility
573
+ * - Consumers should provide labeling via aria-label or aria-labelledby
574
+ * on this component, as no implicit label is rendered.
575
+ */
576
+ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>((props, forwardedRef) => {
577
+ const {
578
+ className,
579
+ style,
580
+ asChild,
581
+ onFocus,
582
+ onInput,
583
+ onChange,
584
+ onPaste,
585
+ onKeyDown,
586
+ submitOnEnter = false,
587
+ rows,
588
+ ...textareaProps
589
+ } = props;
590
+ const ctx = useChatbarContext();
591
+ const {
592
+ open,
593
+ minLines,
594
+ maxLines,
595
+ expandOn,
596
+ disabled,
597
+ readOnly,
598
+ setOpen,
599
+ setValue,
600
+ textareaRef,
601
+ value,
602
+ isValueControlled,
603
+ sendMode,
604
+ paste,
605
+ appendFilesFromPaste,
606
+ size,
607
+ } = ctx;
608
+
609
+ // Cached metrics to avoid repeated getComputedStyle calls
610
+ const lineHeightRef = React.useRef<number>(0);
611
+ const paddingRef = React.useRef<number>(0);
612
+ const compactHeightRef = React.useRef<number>(0);
613
+
614
+ const recomputeMetrics = React.useCallback(() => {
615
+ const el = textareaRef.current;
616
+ if (!el) return;
617
+ const computedStyle = window.getComputedStyle(el);
618
+ const lineHeight = parseFloat(computedStyle.lineHeight) || 20;
619
+ const paddingTop = parseFloat(computedStyle.paddingTop) || 0;
620
+ const paddingBottom = parseFloat(computedStyle.paddingBottom) || 0;
621
+ lineHeightRef.current = lineHeight;
622
+ paddingRef.current = paddingTop + paddingBottom;
623
+ compactHeightRef.current = Math.ceil(1 * lineHeight) + paddingTop + paddingBottom;
624
+ }, [textareaRef]);
625
+
626
+ // Auto-resize logic - optimized for fixed widths
627
+ const updateHeight = React.useCallback(
628
+ (forceOpen?: boolean) => {
629
+ const textarea = textareaRef.current;
630
+ if (!textarea) return;
631
+
632
+ textarea.style.height = 'auto';
633
+
634
+ if (lineHeightRef.current === 0) {
635
+ recomputeMetrics();
636
+ }
637
+ const lineHeight = lineHeightRef.current;
638
+ const padding = paddingRef.current;
639
+
640
+ const isOpen = forceOpen ?? open;
641
+ const effectiveMinLines = isOpen ? minLines : 1;
642
+ const effectiveMaxLines = isOpen ? maxLines : 1;
643
+
644
+ const minHeight = Math.ceil(effectiveMinLines * lineHeight) + padding;
645
+ const maxHeight = Math.ceil(effectiveMaxLines * lineHeight) + padding;
646
+
647
+ const contentHeight = Math.max(textarea.scrollHeight, minHeight);
648
+ const finalHeight = Math.min(contentHeight, maxHeight);
649
+
650
+ textarea.style.height = `${finalHeight}px`;
651
+
652
+ if (contentHeight > maxHeight) {
653
+ textarea.style.overflowY = 'auto';
654
+ textarea.style.maxHeight = `${maxHeight}px`;
655
+ } else {
656
+ textarea.style.overflowY = 'hidden';
657
+ textarea.style.maxHeight = 'none';
658
+ }
659
+ },
660
+ [open, minLines, maxLines, textareaRef, recomputeMetrics],
661
+ );
662
+
663
+ // Update height when value or open state changes
664
+ useIsomorphicLayoutEffect(() => {
665
+ updateHeight();
666
+ }, [updateHeight, value, open]);
667
+
668
+ // Recompute metrics on mount and when size changes may affect typography
669
+ useIsomorphicLayoutEffect(() => {
670
+ recomputeMetrics();
671
+ updateHeight();
672
+ }, [recomputeMetrics, updateHeight, size]);
673
+
674
+ // Observe responsive changes that alter line-height
675
+ React.useEffect(() => {
676
+ const el = textareaRef.current;
677
+ if (!el || typeof ResizeObserver === 'undefined') return;
678
+ let prevLineHeight = lineHeightRef.current;
679
+ const ro = new ResizeObserver(() => {
680
+ const computedStyle = window.getComputedStyle(el);
681
+ const lh = parseFloat(computedStyle.lineHeight) || 20;
682
+ if (lh !== prevLineHeight) {
683
+ prevLineHeight = lh;
684
+ recomputeMetrics();
685
+ requestAnimationFrame(() => updateHeight());
686
+ }
687
+ });
688
+ ro.observe(el);
689
+ return () => ro.disconnect();
690
+ }, [textareaRef, recomputeMetrics, updateHeight]);
691
+
692
+ // Dev-only warning if no accessible name is provided
693
+ React.useEffect(() => {
694
+ if (process.env.NODE_ENV === 'production') return;
695
+ const hasLabel =
696
+ textareaProps['aria-label'] != null || textareaProps['aria-labelledby'] != null;
697
+ if (!hasLabel) {
698
+ // eslint-disable-next-line no-console
699
+ console.warn(
700
+ '[Chatbar.Textarea] Provide aria-label or aria-labelledby to ensure the control has an accessible name.',
701
+ );
702
+ }
703
+ // warn only on mount
704
+ // eslint-disable-next-line react-hooks/exhaustive-deps
705
+ }, []);
706
+
707
+ // Note: No MutationObserver is used because <textarea> value changes are not
708
+ // reflected in DOM text nodes. Height updates are handled by effects and events.
709
+
710
+ const handleFocus = React.useCallback<React.FocusEventHandler<HTMLTextAreaElement>>(
711
+ (event) => {
712
+ if (disabled || readOnly) return;
713
+ if ((expandOn === 'focus' || expandOn === 'both') && !open) setOpen(true);
714
+ onFocus?.(event);
715
+ },
716
+ [disabled, readOnly, expandOn, open, setOpen, onFocus],
717
+ );
718
+
719
+ const handleChange = React.useCallback<React.ChangeEventHandler<HTMLTextAreaElement>>(
720
+ (event) => {
721
+ const el = event.currentTarget;
722
+ const nextValue = el.value;
723
+ setValue(nextValue);
724
+
725
+ if ((expandOn === 'overflow' || expandOn === 'both') && !open) {
726
+ el.style.height = 'auto';
727
+
728
+ if (compactHeightRef.current === 0) {
729
+ recomputeMetrics();
730
+ }
731
+ const shouldExpand = el.scrollHeight > compactHeightRef.current + 1;
732
+ if (shouldExpand) {
733
+ setOpen(true);
734
+ // Immediately size for open state to avoid 1-line + scrollbar flash
735
+ updateHeight(true);
736
+ requestAnimationFrame(() => updateHeight(true));
737
+ }
738
+ }
739
+
740
+ // Always recalc after any input
741
+ requestAnimationFrame(() => updateHeight());
742
+ onChange?.(event);
743
+ },
744
+ [expandOn, open, setOpen, setValue, onChange, updateHeight, recomputeMetrics],
745
+ );
746
+
747
+ const handlePaste = React.useCallback<React.ClipboardEventHandler<HTMLTextAreaElement>>(
748
+ (event) => {
749
+ // Attach files from clipboard if enabled
750
+ if (paste) {
751
+ const items = Array.from(event.clipboardData?.items ?? []);
752
+ const files = items
753
+ .filter((i) => i.kind === 'file')
754
+ .map((i) => i.getAsFile())
755
+ .filter((f): f is File => !!f);
756
+ if (files.length > 0) {
757
+ appendFilesFromPaste(files);
758
+ }
759
+ }
760
+ setTimeout(() => {
761
+ // If pasting in compact mode, force sizing as open if content overflowed
762
+ if (!open) {
763
+ updateHeight(true);
764
+ } else {
765
+ updateHeight();
766
+ }
767
+ }, 0);
768
+ onPaste?.(event);
769
+ },
770
+ [paste, open, updateHeight, onPaste, appendFilesFromPaste],
771
+ );
772
+
773
+ const handleKeyDown = React.useCallback<React.KeyboardEventHandler<HTMLTextAreaElement>>(
774
+ (event) => {
775
+ if (!submitOnEnter) {
776
+ onKeyDown?.(event);
777
+ return;
778
+ }
779
+ if (
780
+ event.key === 'Enter' &&
781
+ !event.shiftKey &&
782
+ !event.altKey &&
783
+ !event.ctrlKey &&
784
+ !event.metaKey &&
785
+ !event.nativeEvent.isComposing
786
+ ) {
787
+ if (disabled || readOnly) {
788
+ onKeyDown?.(event);
789
+ return;
790
+ }
791
+ if (sendMode === 'never') {
792
+ onKeyDown?.(event);
793
+ return;
794
+ }
795
+ const trimmed = value.trim();
796
+ if (sendMode === 'whenDirty' && trimmed.length === 0) {
797
+ onKeyDown?.(event);
798
+ return;
799
+ }
800
+ event.preventDefault();
801
+ ctx.onSubmit?.({ value, attachments: ctx.attachments });
802
+ if (ctx.clearOnSubmit) {
803
+ if (!isValueControlled) setValue('');
804
+ ctx.setAttachments([]);
805
+ }
806
+ }
807
+ onKeyDown?.(event);
808
+ },
809
+ [
810
+ submitOnEnter,
811
+ disabled,
812
+ readOnly,
813
+ sendMode,
814
+ value,
815
+ isValueControlled,
816
+ setValue,
817
+ ctx,
818
+ onKeyDown,
819
+ ],
820
+ );
821
+
822
+ const Comp = asChild ? Slot : ('textarea' as any);
823
+ return (
824
+ <div className={classNames('rt-ChatbarField', 'rt-ChatbarTextarea', className)}>
825
+ <Comp
826
+ {...textareaProps}
827
+ ref={(node: HTMLTextAreaElement) => {
828
+ if (typeof forwardedRef === 'function') forwardedRef(node);
829
+ else if (forwardedRef)
830
+ (forwardedRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = node;
831
+ (textareaRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = node;
832
+ }}
833
+ className="rt-ChatbarInput"
834
+ value={value}
835
+ onInput={onInput}
836
+ onChange={handleChange}
837
+ onFocus={handleFocus}
838
+ onPaste={handlePaste}
839
+ onKeyDown={handleKeyDown}
840
+ disabled={disabled}
841
+ readOnly={readOnly}
842
+ rows={open ? minLines : 1}
843
+ spellCheck={textareaProps.spellCheck ?? true}
844
+ autoCorrect={textareaProps.autoCorrect ?? 'on'}
845
+ style={style}
846
+ />
847
+ </div>
848
+ );
849
+ });
850
+ Textarea.displayName = 'Chatbar.Textarea';
851
+
852
+ interface InlineSlotProps extends Omit<React.ComponentPropsWithoutRef<'div'>, 'children'> {
853
+ asChild?: boolean;
854
+ children?: React.ReactNode;
855
+ }
856
+
857
+ const InlineStart = React.forwardRef<HTMLDivElement, InlineSlotProps>((props, forwardedRef) => {
858
+ const { children, asChild, style, className, ...divProps } = props;
859
+ const ctx = useChatbarContext();
860
+ if (ctx.open) return null;
861
+ const Comp = asChild ? Slot : ('div' as any);
862
+ return (
863
+ <Comp
864
+ {...divProps}
865
+ ref={forwardedRef}
866
+ className={classNames('rt-ChatbarInlineStart', className)}
867
+ style={style}
868
+ >
869
+ {children}
870
+ </Comp>
871
+ );
872
+ });
873
+ InlineStart.displayName = 'Chatbar.InlineStart';
874
+
875
+ const InlineEnd = React.forwardRef<HTMLDivElement, InlineSlotProps>((props, forwardedRef) => {
876
+ const { children, asChild, style, className, ...divProps } = props;
877
+ const ctx = useChatbarContext();
878
+ if (ctx.open) return null;
879
+ const Comp = asChild ? Slot : ('div' as any);
880
+ return (
881
+ <Comp
882
+ {...divProps}
883
+ ref={forwardedRef}
884
+ className={classNames('rt-ChatbarInlineEnd', className)}
885
+ style={style}
886
+ >
887
+ {children}
888
+ </Comp>
889
+ );
890
+ });
891
+ InlineEnd.displayName = 'Chatbar.InlineEnd';
892
+
893
+ /**
894
+ * Renders a horizontally scrollable list of attachments above the inline row.
895
+ * Hidden when empty unless `forceMount`. Override per-item with `renderAttachment`.
896
+ */
897
+ interface AttachmentsRowProps extends Omit<React.ComponentPropsWithoutRef<'div'>, 'children'> {
898
+ asChild?: boolean;
899
+ forceMount?: boolean;
900
+ /** If provided, custom-render a tile; otherwise default tile is used */
901
+ renderAttachment?: (attachment: ChatbarAttachment) => React.ReactNode;
902
+ }
903
+
904
+ const AttachmentsRow = React.forwardRef<HTMLDivElement, AttachmentsRowProps>(
905
+ (props, forwardedRef) => {
906
+ const { asChild, forceMount, renderAttachment, className, style, ...divProps } = props;
907
+ const ctx = useChatbarContext();
908
+ const hasItems = ctx.attachments.length > 0;
909
+ if (!hasItems && !forceMount) return null;
910
+ const Comp = asChild ? Slot : ('div' as any);
911
+ return (
912
+ <Comp
913
+ {...divProps}
914
+ ref={forwardedRef}
915
+ className={classNames('rt-ChatbarAttachmentsRow', className)}
916
+ style={style}
917
+ role="list"
918
+ aria-label={divProps['aria-label'] ?? 'Attachments'}
919
+ >
920
+ <ScrollArea className="rt-ChatbarScrollArea" scrollbars="horizontal" size="1">
921
+ <Flex align="center" gap="2" style={{ minWidth: 'fit-content' }}>
922
+ {ctx.attachments.map((att) => (
923
+ <Attachment key={att.id} attachment={att} asChild={!!renderAttachment}>
924
+ {renderAttachment?.(att)}
925
+ </Attachment>
926
+ ))}
927
+ </Flex>
928
+ </ScrollArea>
929
+ </Comp>
930
+ );
931
+ },
932
+ );
933
+ AttachmentsRow.displayName = 'Chatbar.AttachmentsRow';
934
+
935
+ /** Default tile renderer for a single attachment. */
936
+ interface AttachmentProps extends React.ComponentPropsWithoutRef<'div'> {
937
+ attachment: ChatbarAttachment;
938
+ asChild?: boolean;
939
+ }
940
+
941
+ const Attachment = React.forwardRef<HTMLDivElement, AttachmentProps>((props, forwardedRef) => {
942
+ const { attachment, asChild, className, style, children, ...divProps } = props;
943
+ const ctx = useChatbarContext();
944
+ const Comp = asChild ? Slot : ('div' as any);
945
+ const isImage = !!attachment.url && attachment.type.startsWith('image/');
946
+ return (
947
+ <Comp
948
+ {...divProps}
949
+ ref={forwardedRef}
950
+ className={classNames('rt-ChatbarAttachment', className)}
951
+ style={style}
952
+ role="listitem"
953
+ data-kind={isImage ? 'image' : 'file'}
954
+ title={attachment.name}
955
+ >
956
+ {children ?? (
957
+ <Card size={ctx.size} variant="soft">
958
+ <Flex align="center" gap="3" pr={!isImage ? '6' : undefined}>
959
+ <div className="rt-ChatbarAttachmentPreview" aria-hidden>
960
+ {isImage ? (
961
+ <img className="rt-ChatbarAttachmentImage" src={attachment.url} alt="" />
962
+ ) : (
963
+ <FileTextIcon />
964
+ )}
965
+ </div>
966
+ {!isImage && (
967
+ <Flex direction="column" gap="0" style={{ minWidth: 0 }}>
968
+ <Text
969
+ size={ctx.size}
970
+ weight="medium"
971
+ style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
972
+ >
973
+ {attachment.name}
974
+ </Text>
975
+ <Text size="1" color="gray">
976
+ {Math.ceil(attachment.size / 1024)} KB
977
+ </Text>
978
+ </Flex>
979
+ )}
980
+ <IconButton
981
+ className="rt-ChatbarAttachmentRemove"
982
+ aria-label={`Remove ${attachment.name}`}
983
+ size="1"
984
+ // size={ctx.size}
985
+ variant="classic"
986
+ highContrast
987
+ color="gray"
988
+ onClick={() =>
989
+ ctx.setAttachments(ctx.attachments.filter((a) => a.id !== attachment.id))
990
+ }
991
+ >
992
+ <CloseIcon />
993
+ </IconButton>
994
+ </Flex>
995
+ </Card>
996
+ )}
997
+ </Comp>
998
+ );
999
+ });
1000
+ Attachment.displayName = 'Chatbar.Attachment';
1001
+
1002
+ interface AttachTriggerProps extends React.ComponentPropsWithoutRef<'button'> {
1003
+ asChild?: boolean;
1004
+ accept?: string | string[];
1005
+ multiple?: boolean;
1006
+ }
1007
+
1008
+ const AttachTrigger = React.forwardRef<HTMLButtonElement, AttachTriggerProps>(
1009
+ (props, forwardedRef) => {
1010
+ const { asChild, accept, multiple, className, style, ...buttonProps } = props;
1011
+ const ctx = useChatbarContext();
1012
+ const inputRef = React.useRef<HTMLInputElement | null>(null);
1013
+ const Comp = asChild ? Slot : ('button' as any);
1014
+ const actualAccept = (Array.isArray(accept) ? accept : (accept?.split(',') ?? [])).join(',');
1015
+ return (
1016
+ <>
1017
+ <Comp
1018
+ {...(buttonProps as any)}
1019
+ ref={forwardedRef as any}
1020
+ className={classNames('rt-ChatbarAttachTrigger', className)}
1021
+ style={style}
1022
+ type={buttonProps.type ?? 'button'}
1023
+ aria-label={buttonProps['aria-label'] ?? 'Add attachments'}
1024
+ onClick={(e: any) => {
1025
+ // Ensure file input opens reliably by clicking it first
1026
+ if (inputRef.current) {
1027
+ inputRef.current.click();
1028
+ }
1029
+ // Then call user's onClick if provided
1030
+ buttonProps.onClick?.(e);
1031
+ }}
1032
+ />
1033
+ <input
1034
+ ref={inputRef}
1035
+ type="file"
1036
+ accept={actualAccept}
1037
+ multiple={multiple ?? ctx.multiple}
1038
+ tabIndex={-1}
1039
+ style={{ display: 'none' }}
1040
+ onChange={(e) => {
1041
+ const files = Array.from(e.currentTarget.files ?? []);
1042
+ if (files.length > 0) {
1043
+ ctx.appendFiles(files);
1044
+ }
1045
+ // Reset input value to allow selecting the same file again
1046
+ e.currentTarget.value = '';
1047
+ }}
1048
+ />
1049
+ </>
1050
+ );
1051
+ },
1052
+ );
1053
+ AttachTrigger.displayName = 'Chatbar.AttachTrigger';
1054
+ interface RowProps extends Omit<React.ComponentPropsWithoutRef<'div'>, 'children'> {
1055
+ asChild?: boolean;
1056
+ children?: React.ReactNode;
1057
+ }
1058
+
1059
+ const Row = React.forwardRef<HTMLDivElement, RowProps>((props, forwardedRef) => {
1060
+ const { asChild, children, className, style, ...divProps } = props;
1061
+ const ctx = useChatbarContext();
1062
+ if (!ctx.open) return null;
1063
+ const Comp = asChild ? Slot : ('div' as any);
1064
+ return (
1065
+ <Comp
1066
+ {...divProps}
1067
+ ref={forwardedRef}
1068
+ className={classNames('rt-ChatbarRow', className)}
1069
+ style={style}
1070
+ >
1071
+ <Flex align="center" justify="between" width="100%">
1072
+ {children}
1073
+ </Flex>
1074
+ </Comp>
1075
+ );
1076
+ });
1077
+ Row.displayName = 'Chatbar.Row';
1078
+
1079
+ const RowStart = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(
1080
+ (props, forwardedRef) => {
1081
+ const { className, style, ...divProps } = props;
1082
+ return (
1083
+ <div
1084
+ {...divProps}
1085
+ ref={forwardedRef}
1086
+ className={classNames('rt-ChatbarRowStart', className)}
1087
+ style={style}
1088
+ />
1089
+ );
1090
+ },
1091
+ );
1092
+ RowStart.displayName = 'Chatbar.RowStart';
1093
+
1094
+ const RowEnd = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(
1095
+ (props, forwardedRef) => {
1096
+ const { className, style, ...divProps } = props;
1097
+ return (
1098
+ <div
1099
+ {...divProps}
1100
+ ref={forwardedRef}
1101
+ className={classNames('rt-ChatbarRowEnd', className)}
1102
+ style={style}
1103
+ />
1104
+ );
1105
+ },
1106
+ );
1107
+ RowEnd.displayName = 'Chatbar.RowEnd';
1108
+
1109
+ interface SendProps extends Omit<IconButtonProps, 'size'> {
1110
+ asChild?: boolean;
1111
+ clearOnSend?: boolean;
1112
+ }
1113
+
1114
+ const Send = React.forwardRef<HTMLButtonElement, SendProps>((props, forwardedRef) => {
1115
+ const {
1116
+ asChild,
1117
+ clearOnSend = true,
1118
+ disabled,
1119
+ children,
1120
+ className,
1121
+ style,
1122
+ ...buttonProps
1123
+ } = props;
1124
+ const ctx = useChatbarContext();
1125
+
1126
+ const trimmed = ctx.value.trim();
1127
+ const visible = ctx.sendMode === 'always' || (ctx.sendMode === 'whenDirty' && trimmed.length > 0);
1128
+ if (ctx.sendMode === 'never') return null;
1129
+
1130
+ const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
1131
+ if (ctx.disabled || ctx.readOnly) return;
1132
+ ctx.onSubmit?.({ value: ctx.value, attachments: ctx.attachments });
1133
+ if (clearOnSend) {
1134
+ if (!ctx.isValueControlled) ctx.setValue('');
1135
+ if (ctx.clearOnSubmit) ctx.setAttachments([]);
1136
+ }
1137
+ buttonProps.onClick?.(event);
1138
+ };
1139
+
1140
+ return (
1141
+ <IconButton
1142
+ {...(buttonProps as any)}
1143
+ ref={forwardedRef as any}
1144
+ size={ctx.size}
1145
+ variant={ctx.open ? 'solid' : 'ghost'}
1146
+ disabled={disabled || ctx.disabled || ctx.readOnly}
1147
+ className={classNames('rt-ChatbarSend', className)}
1148
+ style={{
1149
+ opacity: visible ? 1 : 0,
1150
+ pointerEvents: visible ? 'auto' : 'none',
1151
+ ...style,
1152
+ }}
1153
+ asChild={asChild}
1154
+ onClick={handleClick}
1155
+ aria-label={buttonProps['aria-label'] ?? 'Send'}
1156
+ >
1157
+ {children ?? 'Send'}
1158
+ </IconButton>
1159
+ );
1160
+ });
1161
+ Send.displayName = 'Chatbar.Send';
1162
+
1163
+ export {
1164
+ Root,
1165
+ Textarea,
1166
+ InlineStart,
1167
+ InlineEnd,
1168
+ AttachmentsRow,
1169
+ Attachment,
1170
+ AttachTrigger,
1171
+ Row,
1172
+ RowStart,
1173
+ RowEnd,
1174
+ Send,
1175
+ };
1176
+ export type {
1177
+ RootProps as ChatbarRootProps,
1178
+ TextareaProps as ChatbarTextareaProps,
1179
+ RowProps as ChatbarRowProps,
1180
+ SendProps as ChatbarSendProps,
1181
+ };