@kushagradhawan/kookie-ui 0.1.50 → 0.1.52

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 (94) hide show
  1. package/components.css +582 -116
  2. package/dist/cjs/components/_internal/shell-bottom.d.ts +31 -5
  3. package/dist/cjs/components/_internal/shell-bottom.d.ts.map +1 -1
  4. package/dist/cjs/components/_internal/shell-bottom.js +1 -1
  5. package/dist/cjs/components/_internal/shell-bottom.js.map +3 -3
  6. package/dist/cjs/components/_internal/shell-handles.d.ts.map +1 -1
  7. package/dist/cjs/components/_internal/shell-handles.js +1 -1
  8. package/dist/cjs/components/_internal/shell-handles.js.map +3 -3
  9. package/dist/cjs/components/_internal/shell-inspector.d.ts +23 -5
  10. package/dist/cjs/components/_internal/shell-inspector.d.ts.map +1 -1
  11. package/dist/cjs/components/_internal/shell-inspector.js +1 -1
  12. package/dist/cjs/components/_internal/shell-inspector.js.map +3 -3
  13. package/dist/cjs/components/_internal/shell-sidebar.d.ts +24 -6
  14. package/dist/cjs/components/_internal/shell-sidebar.d.ts.map +1 -1
  15. package/dist/cjs/components/_internal/shell-sidebar.js +1 -1
  16. package/dist/cjs/components/_internal/shell-sidebar.js.map +3 -3
  17. package/dist/cjs/components/chatbar.d.ts +21 -2
  18. package/dist/cjs/components/chatbar.d.ts.map +1 -1
  19. package/dist/cjs/components/chatbar.js +1 -1
  20. package/dist/cjs/components/chatbar.js.map +3 -3
  21. package/dist/cjs/components/shell.context.d.ts +88 -1
  22. package/dist/cjs/components/shell.context.d.ts.map +1 -1
  23. package/dist/cjs/components/shell.context.js +1 -1
  24. package/dist/cjs/components/shell.context.js.map +3 -3
  25. package/dist/cjs/components/shell.d.ts +51 -13
  26. package/dist/cjs/components/shell.d.ts.map +1 -1
  27. package/dist/cjs/components/shell.hooks.d.ts +7 -1
  28. package/dist/cjs/components/shell.hooks.d.ts.map +1 -1
  29. package/dist/cjs/components/shell.hooks.js +1 -1
  30. package/dist/cjs/components/shell.hooks.js.map +3 -3
  31. package/dist/cjs/components/shell.js +1 -1
  32. package/dist/cjs/components/shell.js.map +3 -3
  33. package/dist/cjs/components/shell.types.d.ts +1 -0
  34. package/dist/cjs/components/shell.types.d.ts.map +1 -1
  35. package/dist/cjs/components/shell.types.js +1 -1
  36. package/dist/cjs/components/shell.types.js.map +2 -2
  37. package/dist/esm/components/_internal/shell-bottom.d.ts +31 -5
  38. package/dist/esm/components/_internal/shell-bottom.d.ts.map +1 -1
  39. package/dist/esm/components/_internal/shell-bottom.js +1 -1
  40. package/dist/esm/components/_internal/shell-bottom.js.map +3 -3
  41. package/dist/esm/components/_internal/shell-handles.d.ts.map +1 -1
  42. package/dist/esm/components/_internal/shell-handles.js +1 -1
  43. package/dist/esm/components/_internal/shell-handles.js.map +3 -3
  44. package/dist/esm/components/_internal/shell-inspector.d.ts +23 -5
  45. package/dist/esm/components/_internal/shell-inspector.d.ts.map +1 -1
  46. package/dist/esm/components/_internal/shell-inspector.js +1 -1
  47. package/dist/esm/components/_internal/shell-inspector.js.map +3 -3
  48. package/dist/esm/components/_internal/shell-sidebar.d.ts +24 -6
  49. package/dist/esm/components/_internal/shell-sidebar.d.ts.map +1 -1
  50. package/dist/esm/components/_internal/shell-sidebar.js +1 -1
  51. package/dist/esm/components/_internal/shell-sidebar.js.map +3 -3
  52. package/dist/esm/components/chatbar.d.ts +21 -2
  53. package/dist/esm/components/chatbar.d.ts.map +1 -1
  54. package/dist/esm/components/chatbar.js +1 -1
  55. package/dist/esm/components/chatbar.js.map +3 -3
  56. package/dist/esm/components/shell.context.d.ts +88 -1
  57. package/dist/esm/components/shell.context.d.ts.map +1 -1
  58. package/dist/esm/components/shell.context.js +1 -1
  59. package/dist/esm/components/shell.context.js.map +3 -3
  60. package/dist/esm/components/shell.d.ts +51 -13
  61. package/dist/esm/components/shell.d.ts.map +1 -1
  62. package/dist/esm/components/shell.hooks.d.ts +7 -1
  63. package/dist/esm/components/shell.hooks.d.ts.map +1 -1
  64. package/dist/esm/components/shell.hooks.js +1 -1
  65. package/dist/esm/components/shell.hooks.js.map +3 -3
  66. package/dist/esm/components/shell.js +1 -1
  67. package/dist/esm/components/shell.js.map +3 -3
  68. package/dist/esm/components/shell.types.d.ts +1 -0
  69. package/dist/esm/components/shell.types.d.ts.map +1 -1
  70. package/dist/esm/components/shell.types.js.map +2 -2
  71. package/package.json +14 -3
  72. package/schemas/base-button.json +1 -1
  73. package/schemas/button.json +1 -1
  74. package/schemas/icon-button.json +1 -1
  75. package/schemas/index.json +6 -6
  76. package/schemas/toggle-button.json +1 -1
  77. package/schemas/toggle-icon-button.json +1 -1
  78. package/src/components/_internal/base-menu.css +16 -16
  79. package/src/components/_internal/base-sidebar-menu.css +23 -20
  80. package/src/components/_internal/base-sidebar.css +13 -0
  81. package/src/components/_internal/shell-bottom.tsx +176 -49
  82. package/src/components/_internal/shell-handles.tsx +29 -4
  83. package/src/components/_internal/shell-inspector.tsx +175 -43
  84. package/src/components/_internal/shell-sidebar.tsx +177 -93
  85. package/src/components/chatbar.css +240 -21
  86. package/src/components/chatbar.tsx +280 -291
  87. package/src/components/sheet.css +8 -16
  88. package/src/components/shell.context.tsx +79 -3
  89. package/src/components/shell.css +0 -1
  90. package/src/components/shell.hooks.ts +35 -0
  91. package/src/components/shell.tsx +574 -235
  92. package/src/components/shell.types.ts +2 -0
  93. package/src/components/sidebar.css +2 -2
  94. package/styles.css +582 -116
@@ -7,14 +7,15 @@ import { Flex } from './flex.js';
7
7
 
8
8
  import { ScrollArea } from './scroll-area.js';
9
9
  import { Slot } from './slot.js';
10
+ import { Box } from './box.js';
10
11
  import { Card } from './card.js';
11
12
  import { Text } from './text.js';
13
+ import { Inset } from './inset.js';
12
14
  import { useDropzone } from 'react-dropzone';
13
15
  import type { ComponentPropsWithout, RemovedProps } from '../helpers/component-props.js';
14
16
 
15
17
  // Avoid SSR warnings by using an isomorphic layout effect
16
- const useIsomorphicLayoutEffect =
17
- typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;
18
+ const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;
18
19
 
19
20
  type ExpandOn = 'none' | 'focus' | 'overflow' | 'both';
20
21
  type SendMode = 'always' | 'whenDirty' | 'never';
@@ -86,6 +87,9 @@ interface ChatbarContextValue {
86
87
  // Helpers
87
88
  appendFiles(files: File[]): void;
88
89
  appendFilesFromPaste(files: File[]): void;
90
+
91
+ // Guards
92
+ fileDialogOpenRef: React.MutableRefObject<boolean>;
89
93
  }
90
94
 
91
95
  const ChatbarContext = React.createContext<ChatbarContextValue | null>(null);
@@ -136,6 +140,13 @@ interface ChatbarRootBaseProps {
136
140
 
137
141
  size?: '1' | '2' | '3';
138
142
  variant?: 'surface' | 'outline' | 'classic' | 'ghost' | 'soft';
143
+ /** Accent color for the control (matches TextArea) */
144
+ color?: string;
145
+ /** Optional radius override (matches TextArea) */
146
+ radius?: string | number;
147
+ /** Panel/material translucency flags (matches TextArea) */
148
+ panelBackground?: 'solid' | 'translucent';
149
+ material?: 'solid' | 'translucent';
139
150
 
140
151
  width?: React.CSSProperties['width'];
141
152
  maxWidth?: React.CSSProperties['maxWidth'];
@@ -166,9 +177,22 @@ interface ChatbarRootBaseProps {
166
177
  * @default true
167
178
  */
168
179
  dropzone?: boolean;
180
+
181
+ /**
182
+ * Optional API ref to control Chatbar imperatively without relying on DOM refs.
183
+ * Provides methods to focus the textarea and open the file picker.
184
+ */
185
+ apiRef?: React.Ref<ChatbarApi>;
169
186
  }
170
187
 
171
188
  type RootElement = React.ElementRef<'div'>;
189
+ /** Imperative API for Chatbar.Root */
190
+ export interface ChatbarApi {
191
+ /** Focus the textarea input */
192
+ focusTextarea: () => void;
193
+ /** Open the file picker dialog (respects accept/multiple) */
194
+ openFilePicker: () => void;
195
+ }
172
196
  /**
173
197
  * Chatbar container and state provider.
174
198
  *
@@ -199,9 +223,7 @@ type RootElement = React.ElementRef<'div'>;
199
223
  * - `aria-expanded` on the root reflects the disclosure state of the input area.
200
224
  * - Dropzone provides proper ARIA attributes for drag and drop operations.
201
225
  */
202
- interface RootProps
203
- extends ComponentPropsWithout<'div', RemovedProps | 'onSubmit'>,
204
- ChatbarRootBaseProps {}
226
+ interface RootProps extends ComponentPropsWithout<'div', RemovedProps | 'onSubmit'>, ChatbarRootBaseProps {}
205
227
 
206
228
  const Root = React.forwardRef<RootElement, RootProps>((props, forwardedRef) => {
207
229
  const {
@@ -223,6 +245,10 @@ const Root = React.forwardRef<RootElement, RootProps>((props, forwardedRef) => {
223
245
  onSubmit,
224
246
  size = '2',
225
247
  variant,
248
+ color,
249
+ radius,
250
+ panelBackground,
251
+ material,
226
252
  width,
227
253
  maxWidth,
228
254
  asChild,
@@ -238,8 +264,10 @@ const Root = React.forwardRef<RootElement, RootProps>((props, forwardedRef) => {
238
264
  clearOnSubmit = true,
239
265
  onAttachmentReject,
240
266
  dropzone = true,
267
+ apiRef,
241
268
  ...divProps
242
269
  } = props;
270
+ const effectiveMaterial = material || panelBackground;
243
271
 
244
272
  const isValueControlled = valueProp != null;
245
273
  const [valueUncontrolled, setValueUncontrolled] = React.useState<string>(defaultValue);
@@ -251,23 +279,20 @@ const Root = React.forwardRef<RootElement, RootProps>((props, forwardedRef) => {
251
279
 
252
280
  const rootRef = React.useRef<HTMLDivElement>(null);
253
281
  const textareaRef = React.useRef<HTMLTextAreaElement>(null);
282
+ const fileDialogOpenRef = React.useRef<boolean>(false);
254
283
 
255
284
  // Attachments state
256
285
  // Treat `attachments` as controlled if the prop is provided, even if its value is `undefined`.
257
286
  // This avoids switching between controlled and uncontrolled when a consumer sets
258
287
  // `attachments={undefined}` to clear attachments. In that case we normalize to an empty array.
259
288
  const isAttachmentsControlled = 'attachments' in props;
260
- const [attachmentsUncontrolled, setAttachmentsUncontrolled] =
261
- React.useState<ChatbarAttachment[]>(defaultAttachments);
262
- const attachments = isAttachmentsControlled
263
- ? ((attachmentsProp ?? []) as ChatbarAttachment[])
264
- : attachmentsUncontrolled;
289
+ const [attachmentsUncontrolled, setAttachmentsUncontrolled] = React.useState<ChatbarAttachment[]>(defaultAttachments);
290
+ const attachments = isAttachmentsControlled ? ((attachmentsProp ?? []) as ChatbarAttachment[]) : attachmentsUncontrolled;
265
291
 
266
292
  // Track generated object URLs for cleanup
267
293
  const generatedUrlSetRef = React.useRef<Set<string>>(new Set());
268
294
 
269
- const toArray = (val: string | string[] | undefined) =>
270
- Array.isArray(val) ? val : typeof val === 'string' ? val.split(',').map((s) => s.trim()) : [];
295
+ const toArray = (val: string | string[] | undefined) => (Array.isArray(val) ? val : typeof val === 'string' ? val.split(',').map((s) => s.trim()) : []);
271
296
 
272
297
  const accepts = toArray(accept);
273
298
  const pasteAccepts = toArray(pasteAccept).length > 0 ? toArray(pasteAccept) : accepts;
@@ -282,8 +307,7 @@ const Root = React.forwardRef<RootElement, RootProps>((props, forwardedRef) => {
282
307
  // MIME pattern
283
308
  const [type, subtype] = pat.split('/');
284
309
  const [fmType, fmSubtype] = mime.split('/');
285
- if (type === '*' || (type === fmType && (subtype === '*' || subtype === fmSubtype)))
286
- return true;
310
+ if (type === '*' || (type === fmType && (subtype === '*' || subtype === fmSubtype))) return true;
287
311
  } else if (pat.startsWith('.')) {
288
312
  if (name.endsWith(pat)) return true;
289
313
  }
@@ -303,10 +327,7 @@ const Root = React.forwardRef<RootElement, RootProps>((props, forwardedRef) => {
303
327
  const next: ChatbarAttachment[] = [];
304
328
  const rejected: { file: File; reason: 'type' | 'size' | 'count' }[] = [];
305
329
 
306
- const remainingSlots =
307
- typeof maxAttachments === 'number'
308
- ? Math.max(maxAttachments - attachments.length, 0)
309
- : Infinity;
330
+ const remainingSlots = typeof maxAttachments === 'number' ? Math.max(maxAttachments - attachments.length, 0) : Infinity;
310
331
 
311
332
  for (const file of files) {
312
333
  if (next.length >= remainingSlots) {
@@ -345,6 +366,9 @@ const Root = React.forwardRef<RootElement, RootProps>((props, forwardedRef) => {
345
366
  const merged = attachments.concat(accepted);
346
367
  if (!isAttachmentsControlled) setAttachmentsUncontrolled(merged);
347
368
  onAttachmentsChange?.(merged);
369
+ // Ensure chatbar expands when attachments are added
370
+ if (!isOpenControlled) setOpenUncontrolled(true);
371
+ onOpenChangeProp?.(true);
348
372
  }
349
373
  if (rejected.length > 0) onAttachmentReject?.(rejected);
350
374
  };
@@ -359,10 +383,7 @@ const Root = React.forwardRef<RootElement, RootProps>((props, forwardedRef) => {
359
383
  const rejected: { file: File; reason: 'type' | 'size' | 'count' }[] = [];
360
384
 
361
385
  // Enforce maxAttachments and maxFileSize
362
- const remainingSlots =
363
- typeof maxAttachments === 'number'
364
- ? Math.max(maxAttachments - attachments.length, 0)
365
- : Infinity;
386
+ const remainingSlots = typeof maxAttachments === 'number' ? Math.max(maxAttachments - attachments.length, 0) : Infinity;
366
387
  for (const file of files) {
367
388
  if (acceptedFiles.length >= remainingSlots) {
368
389
  rejected.push({ file, reason: 'count' });
@@ -412,29 +433,32 @@ const Root = React.forwardRef<RootElement, RootProps>((props, forwardedRef) => {
412
433
  if (!rootEl) return;
413
434
  // If focus remains within root, ignore
414
435
  if (nextTarget && rootEl.contains(nextTarget)) return;
436
+ // If native file dialog is open, avoid collapsing on blur
437
+ if (fileDialogOpenRef.current) return;
415
438
  // Collapse when leaving the root if the value is empty
416
- if ((value?.trim?.() ?? '').length === 0) {
439
+ // Only collapse when both message and attachments are empty
440
+ if ((value?.trim?.() ?? '').length === 0 && attachments.length === 0) {
417
441
  if (!isOpenControlled) setOpenUncontrolled(false);
418
442
  onOpenChangeProp?.(false);
419
443
  }
420
444
  },
421
- [isOpenControlled, onOpenChangeProp, value],
445
+ [isOpenControlled, onOpenChangeProp, value, attachments],
422
446
  );
423
447
 
424
448
  // Dropzone functionality
425
- const { getRootProps, getInputProps, isDragActive } = useDropzone({
449
+ const {
450
+ getRootProps,
451
+ getInputProps,
452
+ isDragActive,
453
+ open: openFileDialog,
454
+ } = useDropzone({
426
455
  onDrop: (acceptedFiles, rejectedFiles) => {
427
456
  if (acceptedFiles.length > 0) {
428
457
  appendFiles(acceptedFiles);
429
458
  }
430
459
  if (rejectedFiles.length > 0 && onAttachmentReject) {
431
460
  const rejections = rejectedFiles.map(({ file, errors }) => {
432
- const reason =
433
- errors[0]?.code === 'file-too-large'
434
- ? 'size'
435
- : errors[0]?.code === 'file-invalid-type'
436
- ? 'type'
437
- : 'count';
461
+ const reason = errors[0]?.code === 'file-too-large' ? 'size' : errors[0]?.code === 'file-invalid-type' ? 'type' : 'count';
438
462
  return { file, reason: reason as 'type' | 'size' | 'count' };
439
463
  });
440
464
  onAttachmentReject(rejections);
@@ -463,6 +487,41 @@ const Root = React.forwardRef<RootElement, RootProps>((props, forwardedRef) => {
463
487
  disabled: !dropzone || disabled,
464
488
  });
465
489
 
490
+ // Expose imperative API via apiRef (non-breaking; forwardedRef still receives the DOM node)
491
+ React.useImperativeHandle(
492
+ apiRef,
493
+ () => ({
494
+ focusTextarea: () => textareaRef.current?.focus({ preventScroll: true }),
495
+ openFilePicker: () => {
496
+ // Guard against blur-collapse while native dialog is open
497
+ fileDialogOpenRef.current = true;
498
+ openFileDialog();
499
+ },
500
+ }),
501
+ [openFileDialog],
502
+ );
503
+
504
+ // Click-to-focus: focus textarea when clicking non-interactive areas inside the container
505
+ const isInteractiveTarget = React.useCallback((target: EventTarget | null) => {
506
+ if (!(target instanceof Element)) return false;
507
+ const el = target as Element;
508
+ if (el.closest('.rt-ChatbarDropOverlay')) return true;
509
+ return !!el.closest('button, [role="button"], a[href], input, textarea, select, [contenteditable], [tabindex]:not([tabindex="-1"])');
510
+ }, []);
511
+
512
+ const handleContainerPointerDown = React.useCallback(
513
+ (event: React.PointerEvent) => {
514
+ if (disabled) return;
515
+ if (isInteractiveTarget(event.target)) return;
516
+ if (event.pointerType === 'mouse' && event.button !== 0) return;
517
+ event.preventDefault();
518
+ textareaRef.current?.focus({ preventScroll: true });
519
+ },
520
+ [disabled, isInteractiveTarget, textareaRef],
521
+ );
522
+
523
+ // Clicking the label-wrapped Card will naturally focus the nested textarea.
524
+
466
525
  return (
467
526
  <ChatbarContext.Provider
468
527
  value={{
@@ -505,14 +564,14 @@ const Root = React.forwardRef<RootElement, RootProps>((props, forwardedRef) => {
505
564
  dropzone,
506
565
  appendFiles,
507
566
  appendFilesFromPaste,
567
+ fileDialogOpenRef,
508
568
  }}
509
569
  >
510
570
  <Comp
511
571
  {...divProps}
512
572
  ref={(node: HTMLDivElement) => {
513
573
  if (typeof forwardedRef === 'function') forwardedRef(node);
514
- else if (forwardedRef)
515
- (forwardedRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
574
+ else if (forwardedRef) (forwardedRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
516
575
  (rootRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
517
576
  }}
518
577
  className={classNames('rt-ChatbarRoot', `rt-r-size-${size}`, className)}
@@ -521,16 +580,22 @@ const Root = React.forwardRef<RootElement, RootProps>((props, forwardedRef) => {
521
580
  data-disabled={disabled ? '' : undefined}
522
581
  data-readonly={readOnly ? '' : undefined}
523
582
  data-drop-active={dropzone && isDragActive ? '' : undefined}
583
+ data-accent-color={color}
584
+ data-radius={radius as any}
585
+ data-panel-background={effectiveMaterial}
586
+ data-material={effectiveMaterial}
524
587
  aria-expanded={open}
525
588
  onBlurCapture={handleBlurCapture}
526
589
  >
527
590
  {dropzone && <input {...getInputProps()} />}
528
- <div {...(dropzone ? getRootProps() : {})} style={{ width: '100%', height: '100%' }}>
529
- <Card
530
- className="rt-ChatbarCard"
531
- size={Math.min(3, Number(size) + 1).toString() as '1' | '2' | '3'}
532
- variant={variant as any}
591
+ <div {...(dropzone ? getRootProps() : {})} style={{ width: '100%', height: '100%' }} onPointerDown={handleContainerPointerDown}>
592
+ <Box
593
+ className={classNames('rt-ChatbarBox', `rt-variant-${variant ?? 'surface'}`)}
533
594
  style={{ position: 'relative' }}
595
+ data-accent-color={color}
596
+ data-radius={radius as any}
597
+ data-panel-background={effectiveMaterial}
598
+ data-material={effectiveMaterial}
534
599
  >
535
600
  <div className="rt-ChatbarGrid">{children}</div>
536
601
  {dropzone && isDragActive && (
@@ -542,7 +607,7 @@ const Root = React.forwardRef<RootElement, RootProps>((props, forwardedRef) => {
542
607
  </div>
543
608
  </div>
544
609
  )}
545
- </Card>
610
+ </Box>
546
611
  </div>
547
612
  </Comp>
548
613
  </ChatbarContext.Provider>
@@ -588,37 +653,9 @@ interface TextareaProps extends Omit<React.ComponentPropsWithoutRef<'textarea'>,
588
653
  * on this component, as no implicit label is rendered.
589
654
  */
590
655
  const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>((props, forwardedRef) => {
591
- const {
592
- className,
593
- style,
594
- asChild,
595
- onFocus,
596
- onInput,
597
- onChange,
598
- onPaste,
599
- onKeyDown,
600
- submitOnEnter = false,
601
- rows,
602
- ...textareaProps
603
- } = props;
656
+ const { className, style, asChild, onFocus, onInput, onChange, onPaste, onKeyDown, submitOnEnter = false, rows, ...textareaProps } = props;
604
657
  const ctx = useChatbarContext();
605
- const {
606
- open,
607
- minLines,
608
- maxLines,
609
- expandOn,
610
- disabled,
611
- readOnly,
612
- setOpen,
613
- setValue,
614
- textareaRef,
615
- value,
616
- isValueControlled,
617
- sendMode,
618
- paste,
619
- appendFilesFromPaste,
620
- size,
621
- } = ctx;
658
+ const { open, minLines, maxLines, expandOn, disabled, readOnly, setOpen, setValue, textareaRef, value, isValueControlled, sendMode, paste, appendFilesFromPaste, size } = ctx;
622
659
 
623
660
  // Cached metrics to avoid repeated getComputedStyle calls
624
661
  const lineHeightRef = React.useRef<number>(0);
@@ -679,6 +716,26 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>((props, fo
679
716
  updateHeight();
680
717
  }, [updateHeight, value, open]);
681
718
 
719
+ // Auto-open on external value changes when content overflows one line
720
+ useIsomorphicLayoutEffect(() => {
721
+ if (!(expandOn === 'overflow' || expandOn === 'both')) return;
722
+ if (open) return;
723
+ const el = textareaRef.current;
724
+ if (!el) return;
725
+ // Measure overflow against compact (1-line) height
726
+ el.style.height = 'auto';
727
+ if (compactHeightRef.current === 0) {
728
+ recomputeMetrics();
729
+ }
730
+ const shouldExpand = el.scrollHeight > compactHeightRef.current + 1;
731
+ if (shouldExpand) {
732
+ setOpen(true);
733
+ // Immediately size for open state
734
+ updateHeight(true);
735
+ requestAnimationFrame(() => updateHeight(true));
736
+ }
737
+ }, [value, expandOn, open, setOpen, textareaRef, recomputeMetrics, updateHeight]);
738
+
682
739
  // Recompute metrics on mount and when size changes may affect typography
683
740
  useIsomorphicLayoutEffect(() => {
684
741
  recomputeMetrics();
@@ -706,13 +763,10 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>((props, fo
706
763
  // Dev-only warning if no accessible name is provided
707
764
  React.useEffect(() => {
708
765
  if (process.env.NODE_ENV === 'production') return;
709
- const hasLabel =
710
- textareaProps['aria-label'] != null || textareaProps['aria-labelledby'] != null;
766
+ const hasLabel = textareaProps['aria-label'] != null || textareaProps['aria-labelledby'] != null;
711
767
  if (!hasLabel) {
712
768
  // eslint-disable-next-line no-console
713
- console.warn(
714
- '[Chatbar.Textarea] Provide aria-label or aria-labelledby to ensure the control has an accessible name.',
715
- );
769
+ console.warn('[Chatbar.Textarea] Provide aria-label or aria-labelledby to ensure the control has an accessible name.');
716
770
  }
717
771
  // warn only on mount
718
772
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -768,6 +822,8 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>((props, fo
768
822
  .map((i) => i.getAsFile())
769
823
  .filter((f): f is File => !!f);
770
824
  if (files.length > 0) {
825
+ // Prevent pasting the file name or any text representation when files are present
826
+ event.preventDefault();
771
827
  appendFilesFromPaste(files);
772
828
  }
773
829
  }
@@ -790,14 +846,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>((props, fo
790
846
  onKeyDown?.(event);
791
847
  return;
792
848
  }
793
- if (
794
- event.key === 'Enter' &&
795
- !event.shiftKey &&
796
- !event.altKey &&
797
- !event.ctrlKey &&
798
- !event.metaKey &&
799
- !event.nativeEvent.isComposing
800
- ) {
849
+ if (event.key === 'Enter' && !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey && !event.nativeEvent.isComposing) {
801
850
  if (disabled || readOnly) {
802
851
  onKeyDown?.(event);
803
852
  return;
@@ -821,17 +870,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>((props, fo
821
870
  }
822
871
  onKeyDown?.(event);
823
872
  },
824
- [
825
- submitOnEnter,
826
- disabled,
827
- readOnly,
828
- sendMode,
829
- value,
830
- isValueControlled,
831
- setValue,
832
- ctx,
833
- onKeyDown,
834
- ],
873
+ [submitOnEnter, disabled, readOnly, sendMode, value, isValueControlled, setValue, ctx, onKeyDown],
835
874
  );
836
875
 
837
876
  const Comp = asChild ? Slot : ('textarea' as any);
@@ -841,8 +880,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>((props, fo
841
880
  {...textareaProps}
842
881
  ref={(node: HTMLTextAreaElement) => {
843
882
  if (typeof forwardedRef === 'function') forwardedRef(node);
844
- else if (forwardedRef)
845
- (forwardedRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = node;
883
+ else if (forwardedRef) (forwardedRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = node;
846
884
  (textareaRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = node;
847
885
  }}
848
886
  className="rt-ChatbarInput"
@@ -875,12 +913,7 @@ const InlineStart = React.forwardRef<HTMLDivElement, InlineSlotProps>((props, fo
875
913
  if (ctx.open) return null;
876
914
  const Comp = asChild ? Slot : ('div' as any);
877
915
  return (
878
- <Comp
879
- {...divProps}
880
- ref={forwardedRef}
881
- className={classNames('rt-ChatbarInlineStart', className)}
882
- style={style}
883
- >
916
+ <Comp {...divProps} ref={forwardedRef} className={classNames('rt-ChatbarInlineStart', className)} style={style}>
884
917
  {children}
885
918
  </Comp>
886
919
  );
@@ -893,12 +926,7 @@ const InlineEnd = React.forwardRef<HTMLDivElement, InlineSlotProps>((props, forw
893
926
  if (ctx.open) return null;
894
927
  const Comp = asChild ? Slot : ('div' as any);
895
928
  return (
896
- <Comp
897
- {...divProps}
898
- ref={forwardedRef}
899
- className={classNames('rt-ChatbarInlineEnd', className)}
900
- style={style}
901
- >
929
+ <Comp {...divProps} ref={forwardedRef} className={classNames('rt-ChatbarInlineEnd', className)} style={style}>
902
930
  {children}
903
931
  </Comp>
904
932
  );
@@ -916,35 +944,26 @@ interface AttachmentsRowProps extends Omit<React.ComponentPropsWithoutRef<'div'>
916
944
  renderAttachment?: (attachment: ChatbarAttachment) => React.ReactNode;
917
945
  }
918
946
 
919
- const AttachmentsRow = React.forwardRef<HTMLDivElement, AttachmentsRowProps>(
920
- (props, forwardedRef) => {
921
- const { asChild, forceMount, renderAttachment, className, style, ...divProps } = props;
922
- const ctx = useChatbarContext();
923
- const hasItems = ctx.attachments.length > 0;
924
- if (!hasItems && !forceMount) return null;
925
- const Comp = asChild ? Slot : ('div' as any);
926
- return (
927
- <Comp
928
- {...divProps}
929
- ref={forwardedRef}
930
- className={classNames('rt-ChatbarAttachmentsRow', className)}
931
- style={style}
932
- role="list"
933
- aria-label={divProps['aria-label'] ?? 'Attachments'}
934
- >
935
- <ScrollArea className="rt-ChatbarScrollArea" scrollbars="horizontal" size="1">
936
- <Flex align="center" gap="2" style={{ minWidth: 'fit-content' }}>
937
- {ctx.attachments.map((att) => (
938
- <Attachment key={att.id} attachment={att} asChild={!!renderAttachment}>
939
- {renderAttachment?.(att)}
940
- </Attachment>
941
- ))}
942
- </Flex>
943
- </ScrollArea>
944
- </Comp>
945
- );
946
- },
947
- );
947
+ const AttachmentsRow = React.forwardRef<HTMLDivElement, AttachmentsRowProps>((props, forwardedRef) => {
948
+ const { asChild, forceMount, renderAttachment, className, style, ...divProps } = props;
949
+ const ctx = useChatbarContext();
950
+ const hasItems = ctx.attachments.length > 0;
951
+ if (!hasItems && !forceMount) return null;
952
+ const Comp = asChild ? Slot : ('div' as any);
953
+ return (
954
+ <Comp {...divProps} ref={forwardedRef} className={classNames('rt-ChatbarAttachmentsRow', className)} style={style} role="list" aria-label={divProps['aria-label'] ?? 'Attachments'}>
955
+ <ScrollArea className="rt-ChatbarScrollArea" scrollbars="horizontal" size="1">
956
+ <Flex align="center" gap="2" style={{ minWidth: 'fit-content' }}>
957
+ {ctx.attachments.map((att) => (
958
+ <Attachment key={att.id} attachment={att} asChild={!!renderAttachment}>
959
+ {renderAttachment?.(att)}
960
+ </Attachment>
961
+ ))}
962
+ </Flex>
963
+ </ScrollArea>
964
+ </Comp>
965
+ );
966
+ });
948
967
  AttachmentsRow.displayName = 'Chatbar.AttachmentsRow';
949
968
 
950
969
  /** Default tile renderer for a single attachment. */
@@ -959,55 +978,37 @@ const Attachment = React.forwardRef<HTMLDivElement, AttachmentProps>((props, for
959
978
  const Comp = asChild ? Slot : ('div' as any);
960
979
  const isImage = !!attachment.url && attachment.type.startsWith('image/');
961
980
  return (
962
- <Comp
963
- {...divProps}
964
- ref={forwardedRef}
965
- className={classNames('rt-ChatbarAttachment', className)}
966
- style={style}
967
- role="listitem"
968
- data-kind={isImage ? 'image' : 'file'}
969
- title={attachment.name}
970
- >
981
+ <Comp {...divProps} ref={forwardedRef} className={classNames('rt-ChatbarAttachment', className)} style={style} role="listitem" data-kind={isImage ? 'image' : 'file'} title={attachment.name}>
971
982
  {children ?? (
972
- <Card size={ctx.size} variant="soft">
973
- <Flex align="center" gap="3" pr={!isImage ? '6' : undefined}>
974
- <div className="rt-ChatbarAttachmentPreview" aria-hidden>
975
- {isImage ? (
976
- <img className="rt-ChatbarAttachmentImage" src={attachment.url} alt="" />
977
- ) : (
978
- <FileTextIcon />
979
- )}
980
- </div>
981
- {!isImage && (
982
- <Flex direction="column" gap="0" style={{ minWidth: 0 }}>
983
- <Text
984
- size={ctx.size}
985
- weight="medium"
986
- style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
987
- >
988
- {attachment.name}
989
- </Text>
990
- <Text size="1" color="gray">
991
- {Math.ceil(attachment.size / 1024)} KB
992
- </Text>
993
- </Flex>
994
- )}
995
- <IconButton
996
- className="rt-ChatbarAttachmentRemove"
997
- aria-label={`Remove ${attachment.name}`}
998
- size="1"
999
- // size={ctx.size}
1000
- variant="classic"
1001
- highContrast
1002
- color="gray"
1003
- onClick={() =>
1004
- ctx.setAttachments(ctx.attachments.filter((a) => a.id !== attachment.id))
1005
- }
1006
- >
1007
- <CloseIcon />
1008
- </IconButton>
1009
- </Flex>
1010
- </Card>
983
+ // <Card size={ctx.size} variant="surface">
984
+ <Flex align="center" gap="2" pr={!isImage ? '6' : undefined}>
985
+ <div className="rt-ChatbarAttachmentPreview" aria-hidden>
986
+ {isImage ? <img className="rt-ChatbarAttachmentImage" src={attachment.url} alt="" /> : <FileTextIcon />}
987
+ </div>
988
+ {!isImage && (
989
+ <Flex direction="column" gap="0" style={{ minWidth: 0 }}>
990
+ <Text size={ctx.size} weight="medium" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
991
+ {attachment.name}
992
+ </Text>
993
+ <Text size="1" color="gray">
994
+ {Math.ceil(attachment.size / 1024)} KB
995
+ </Text>
996
+ </Flex>
997
+ )}
998
+ <IconButton
999
+ className="rt-ChatbarAttachmentRemove"
1000
+ aria-label={`Remove ${attachment.name}`}
1001
+ size="1"
1002
+ // size={ctx.size}
1003
+ variant="classic"
1004
+ highContrast
1005
+ color="gray"
1006
+ onClick={() => ctx.setAttachments(ctx.attachments.filter((a) => a.id !== attachment.id))}
1007
+ >
1008
+ <CloseIcon />
1009
+ </IconButton>
1010
+ </Flex>
1011
+ // </Card>
1011
1012
  )}
1012
1013
  </Comp>
1013
1014
  );
@@ -1020,51 +1021,72 @@ interface AttachTriggerProps extends React.ComponentPropsWithoutRef<'button'> {
1020
1021
  multiple?: boolean;
1021
1022
  }
1022
1023
 
1023
- const AttachTrigger = React.forwardRef<HTMLButtonElement, AttachTriggerProps>(
1024
- (props, forwardedRef) => {
1025
- const { asChild, accept, multiple, className, style, ...buttonProps } = props;
1026
- const ctx = useChatbarContext();
1027
- const inputRef = React.useRef<HTMLInputElement | null>(null);
1028
- const Comp = asChild ? Slot : ('button' as any);
1029
- const actualAccept = (Array.isArray(accept) ? accept : (accept?.split(',') ?? [])).join(',');
1030
- return (
1031
- <>
1032
- <Comp
1033
- {...(buttonProps as any)}
1034
- ref={forwardedRef as any}
1035
- className={classNames('rt-ChatbarAttachTrigger', className)}
1036
- style={style}
1037
- type={buttonProps.type ?? 'button'}
1038
- aria-label={buttonProps['aria-label'] ?? 'Add attachments'}
1039
- onClick={(e: any) => {
1040
- // Ensure file input opens reliably by clicking it first
1041
- if (inputRef.current) {
1042
- inputRef.current.click();
1043
- }
1044
- // Then call user's onClick if provided
1045
- buttonProps.onClick?.(e);
1046
- }}
1047
- />
1048
- <input
1049
- ref={inputRef}
1050
- type="file"
1051
- accept={actualAccept}
1052
- multiple={multiple ?? ctx.multiple}
1053
- tabIndex={-1}
1054
- style={{ display: 'none' }}
1055
- onChange={(e) => {
1056
- const files = Array.from(e.currentTarget.files ?? []);
1057
- if (files.length > 0) {
1058
- ctx.appendFiles(files);
1059
- }
1060
- // Reset input value to allow selecting the same file again
1061
- e.currentTarget.value = '';
1062
- }}
1063
- />
1064
- </>
1065
- );
1066
- },
1067
- );
1024
+ const AttachTrigger = React.forwardRef<HTMLButtonElement, AttachTriggerProps>((props, forwardedRef) => {
1025
+ const { asChild, accept, multiple, className, style, ...buttonProps } = props;
1026
+ const ctx = useChatbarContext();
1027
+ const inputRef = React.useRef<HTMLInputElement | null>(null);
1028
+ const Comp = asChild ? Slot : ('button' as any);
1029
+ // Prefer Chatbar.Root's accept when a local accept is not provided
1030
+ const mergedAccept = accept ?? ctx.accept;
1031
+ const actualAccept = (Array.isArray(mergedAccept) ? mergedAccept : (mergedAccept?.split(',') ?? [])).join(',');
1032
+ React.useEffect(() => {
1033
+ const handleWindowFocus = () => {
1034
+ // Reset guard when window regains focus after file dialog closes
1035
+ ctx.fileDialogOpenRef.current = false;
1036
+ };
1037
+ window.addEventListener('focus', handleWindowFocus);
1038
+ return () => window.removeEventListener('focus', handleWindowFocus);
1039
+ }, [ctx.fileDialogOpenRef]);
1040
+ return (
1041
+ <>
1042
+ <Comp
1043
+ {...(buttonProps as any)}
1044
+ ref={forwardedRef as any}
1045
+ className={classNames('rt-ChatbarAttachTrigger', className)}
1046
+ style={style}
1047
+ type={buttonProps.type ?? 'button'}
1048
+ aria-label={buttonProps['aria-label'] ?? 'Add attachments'}
1049
+ onPointerDown={(e: any) => {
1050
+ // Set guard before blur occurs (Safari fires blur before click)
1051
+ ctx.fileDialogOpenRef.current = true;
1052
+ buttonProps.onPointerDown?.(e);
1053
+ }}
1054
+ onMouseDown={(e: any) => {
1055
+ // Fallback for environments without Pointer Events
1056
+ ctx.fileDialogOpenRef.current = true;
1057
+ buttonProps.onMouseDown?.(e);
1058
+ }}
1059
+ onClick={(e: any) => {
1060
+ // Ensure file input opens reliably by clicking it first
1061
+ ctx.fileDialogOpenRef.current = true;
1062
+ if (inputRef.current) {
1063
+ inputRef.current.click();
1064
+ }
1065
+ // Then call user's onClick if provided
1066
+ buttonProps.onClick?.(e);
1067
+ }}
1068
+ />
1069
+ <input
1070
+ ref={inputRef}
1071
+ type="file"
1072
+ accept={actualAccept}
1073
+ multiple={multiple ?? ctx.multiple}
1074
+ tabIndex={-1}
1075
+ style={{ display: 'none' }}
1076
+ onChange={(e) => {
1077
+ const files = Array.from(e.currentTarget.files ?? []);
1078
+ if (files.length > 0) {
1079
+ ctx.appendFiles(files);
1080
+ }
1081
+ // File dialog closed; allow normal blur handling
1082
+ ctx.fileDialogOpenRef.current = false;
1083
+ // Reset input value to allow selecting the same file again
1084
+ e.currentTarget.value = '';
1085
+ }}
1086
+ />
1087
+ </>
1088
+ );
1089
+ });
1068
1090
  AttachTrigger.displayName = 'Chatbar.AttachTrigger';
1069
1091
  interface RowProps extends Omit<React.ComponentPropsWithoutRef<'div'>, 'children'> {
1070
1092
  asChild?: boolean;
@@ -1077,12 +1099,7 @@ const Row = React.forwardRef<HTMLDivElement, RowProps>((props, forwardedRef) =>
1077
1099
  if (!ctx.open) return null;
1078
1100
  const Comp = asChild ? Slot : ('div' as any);
1079
1101
  return (
1080
- <Comp
1081
- {...divProps}
1082
- ref={forwardedRef}
1083
- className={classNames('rt-ChatbarRow', className)}
1084
- style={style}
1085
- >
1102
+ <Comp {...divProps} ref={forwardedRef} className={classNames('rt-ChatbarRow', className)} style={style}>
1086
1103
  <Flex align="center" justify="between" width="100%">
1087
1104
  {children}
1088
1105
  </Flex>
@@ -1091,34 +1108,16 @@ const Row = React.forwardRef<HTMLDivElement, RowProps>((props, forwardedRef) =>
1091
1108
  });
1092
1109
  Row.displayName = 'Chatbar.Row';
1093
1110
 
1094
- const RowStart = 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-ChatbarRowStart', className)}
1102
- style={style}
1103
- />
1104
- );
1105
- },
1106
- );
1111
+ const RowStart = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>((props, forwardedRef) => {
1112
+ const { className, style, ...divProps } = props;
1113
+ return <div {...divProps} ref={forwardedRef} className={classNames('rt-ChatbarRowStart', className)} style={style} />;
1114
+ });
1107
1115
  RowStart.displayName = 'Chatbar.RowStart';
1108
1116
 
1109
- const RowEnd = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(
1110
- (props, forwardedRef) => {
1111
- const { className, style, ...divProps } = props;
1112
- return (
1113
- <div
1114
- {...divProps}
1115
- ref={forwardedRef}
1116
- className={classNames('rt-ChatbarRowEnd', className)}
1117
- style={style}
1118
- />
1119
- );
1120
- },
1121
- );
1117
+ const RowEnd = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>((props, forwardedRef) => {
1118
+ const { className, style, ...divProps } = props;
1119
+ return <div {...divProps} ref={forwardedRef} className={classNames('rt-ChatbarRowEnd', className)} style={style} />;
1120
+ });
1122
1121
  RowEnd.displayName = 'Chatbar.RowEnd';
1123
1122
 
1124
1123
  interface SendProps extends Omit<IconButtonProps, 'size'> {
@@ -1127,15 +1126,7 @@ interface SendProps extends Omit<IconButtonProps, 'size'> {
1127
1126
  }
1128
1127
 
1129
1128
  const Send = React.forwardRef<HTMLButtonElement, SendProps>((props, forwardedRef) => {
1130
- const {
1131
- asChild,
1132
- clearOnSend = true,
1133
- disabled,
1134
- children,
1135
- className,
1136
- style,
1137
- ...buttonProps
1138
- } = props;
1129
+ const { asChild, clearOnSend = true, disabled, children, className, style, ...buttonProps } = props;
1139
1130
  const ctx = useChatbarContext();
1140
1131
 
1141
1132
  const trimmed = ctx.value.trim();
@@ -1170,29 +1161,27 @@ const Send = React.forwardRef<HTMLButtonElement, SendProps>((props, forwardedRef
1170
1161
  onClick={handleClick}
1171
1162
  aria-label={buttonProps['aria-label'] ?? 'Send'}
1172
1163
  >
1173
- {children ?? 'Send'}
1164
+ {children ?? (
1165
+ <svg
1166
+ xmlns="http://www.w3.org/2000/svg"
1167
+ width="24"
1168
+ height="24"
1169
+ viewBox="0 0 24 24"
1170
+ fill="none"
1171
+ stroke="currentColor"
1172
+ strokeWidth="2"
1173
+ strokeLinecap="round"
1174
+ strokeLinejoin="round"
1175
+ className="lucide lucide-arrow-right-icon lucide-arrow-right"
1176
+ >
1177
+ <path d="M5 12h14" />
1178
+ <path d="m12 5 7 7-7 7" />
1179
+ </svg>
1180
+ )}
1174
1181
  </IconButton>
1175
1182
  );
1176
1183
  });
1177
1184
  Send.displayName = 'Chatbar.Send';
1178
1185
 
1179
- export {
1180
- Root,
1181
- Textarea,
1182
- InlineStart,
1183
- InlineEnd,
1184
- AttachmentsRow,
1185
- Attachment,
1186
- AttachTrigger,
1187
- Row,
1188
- RowStart,
1189
- RowEnd,
1190
- Send,
1191
- };
1192
- export type {
1193
- RootProps as ChatbarRootProps,
1194
- TextareaProps as ChatbarTextareaProps,
1195
- RowProps as ChatbarRowProps,
1196
- SendProps as ChatbarSendProps,
1197
- ChatbarAttachment,
1198
- };
1186
+ export { Root, Textarea, InlineStart, InlineEnd, AttachmentsRow, Attachment, AttachTrigger, Row, RowStart, RowEnd, Send };
1187
+ export type { RootProps as ChatbarRootProps, TextareaProps as ChatbarTextareaProps, RowProps as ChatbarRowProps, SendProps as ChatbarSendProps, ChatbarAttachment };