@djangocfg/ui-tools 2.1.411 → 2.1.412

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 (31) hide show
  1. package/package.json +6 -6
  2. package/src/tools/Chat/composer/AttachContext.tsx +22 -0
  3. package/src/tools/Chat/composer/Composer.tsx +108 -6
  4. package/src/tools/Chat/composer/ComposerMenuButton.tsx +39 -2
  5. package/src/tools/Chat/composer/fileToAttachment.ts +53 -0
  6. package/src/tools/Chat/composer/index.ts +16 -1
  7. package/src/tools/Chat/composer/types.ts +71 -0
  8. package/src/tools/Chat/composer/useComposerAttach.tsx +218 -0
  9. package/src/tools/Chat/hooks/useChat.ts +32 -0
  10. package/src/tools/Chat/hooks/useChatComposer.ts +13 -0
  11. package/src/tools/Chat/messages/MessageBubble.tsx +1 -1
  12. package/src/tools/Chat/public.ts +1 -0
  13. package/src/tools/Chat/types/events.ts +50 -0
  14. package/src/tools/Chat/types/index.ts +1 -1
  15. package/src/tools/Chat/types/message.ts +5 -0
  16. package/src/tools/CronScheduler/CronScheduler.client.tsx +42 -15
  17. package/src/tools/CronScheduler/components/CustomInput.tsx +26 -7
  18. package/src/tools/CronScheduler/components/DayChips.tsx +20 -7
  19. package/src/tools/CronScheduler/components/MonthDayGrid.tsx +35 -10
  20. package/src/tools/CronScheduler/components/SchedulePreview.tsx +8 -5
  21. package/src/tools/CronScheduler/components/ScheduleTypeSelector.tsx +12 -3
  22. package/src/tools/CronScheduler/components/TimeSelector.tsx +36 -13
  23. package/src/tools/CronScheduler/context/CronSchedulerContext.tsx +4 -0
  24. package/src/tools/CronScheduler/context/hooks.ts +8 -0
  25. package/src/tools/CronScheduler/context/index.ts +1 -0
  26. package/src/tools/CronScheduler/index.tsx +2 -0
  27. package/src/tools/CronScheduler/lazy.tsx +1 -0
  28. package/src/tools/CronScheduler/types/index.ts +18 -1
  29. package/src/tools/Map/lazy.tsx +11 -4
  30. package/src/tools/Uploader/hooks/useClipboardPaste.ts +3 -1
  31. package/src/tools/index.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-tools",
3
- "version": "2.1.411",
3
+ "version": "2.1.412",
4
4
  "description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
5
5
  "keywords": [
6
6
  "ui-tools",
@@ -154,8 +154,8 @@
154
154
  "test:watch": "vitest"
155
155
  },
156
156
  "peerDependencies": {
157
- "@djangocfg/i18n": "^2.1.411",
158
- "@djangocfg/ui-core": "^2.1.411",
157
+ "@djangocfg/i18n": "^2.1.412",
158
+ "@djangocfg/ui-core": "^2.1.412",
159
159
  "consola": "^3.4.2",
160
160
  "lodash-es": "^4.18.1",
161
161
  "lucide-react": "^0.545.0",
@@ -210,9 +210,9 @@
210
210
  "@maplibre/maplibre-gl-geocoder": "^1.7.0"
211
211
  },
212
212
  "devDependencies": {
213
- "@djangocfg/i18n": "^2.1.411",
214
- "@djangocfg/typescript-config": "^2.1.411",
215
- "@djangocfg/ui-core": "^2.1.411",
213
+ "@djangocfg/i18n": "^2.1.412",
214
+ "@djangocfg/typescript-config": "^2.1.412",
215
+ "@djangocfg/ui-core": "^2.1.412",
216
216
  "@types/lodash-es": "^4.17.12",
217
217
  "@types/mapbox__mapbox-gl-draw": "^1.4.8",
218
218
  "@types/node": "^25.2.3",
@@ -0,0 +1,22 @@
1
+ 'use client';
2
+
3
+ import { createContext, useContext } from 'react';
4
+
5
+ import type { ComposerAttachHandle } from './types';
6
+
7
+ /**
8
+ * Exposes the composer's attach pipeline to descendants — chiefly
9
+ * `ComposerMenuButton`, so a `+`-menu "Attach" item reaches the same
10
+ * `openPicker` as the paperclip button without a prop drill.
11
+ *
12
+ * `null` when no attach pipeline is mounted (composer without
13
+ * `showAttachmentButton` / `attach` config).
14
+ */
15
+ const AttachContext = createContext<ComposerAttachHandle | null>(null);
16
+
17
+ export const AttachProvider = AttachContext.Provider;
18
+
19
+ /** Read the attach pipeline. Returns `null` outside an attach-enabled composer. */
20
+ export function useComposerAttachContext(): ComposerAttachHandle | null {
21
+ return useContext(AttachContext);
22
+ }
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { forwardRef, useEffect, useRef } from 'react';
3
+ import { forwardRef, useEffect, useRef, useState } from 'react';
4
4
 
5
5
  import { Textarea } from '@djangocfg/ui-core/components';
6
6
  import { cn } from '@djangocfg/ui-core/lib';
@@ -8,13 +8,16 @@ import { cn } from '@djangocfg/ui-core/lib';
8
8
  import { useChatContextOptional } from '../context';
9
9
  import type { UseChatComposerReturn } from '../hooks/useChatComposer';
10
10
  import { Attachments } from '../messages/Attachments';
11
+ import { AttachProvider } from './AttachContext';
11
12
  import { ComposerActionBar } from './ComposerActionBar';
12
13
  import { ComposerButton } from './ComposerButton';
13
14
  import { ComposerFooter } from './ComposerFooter';
14
15
  import { ComposerSizeProvider } from './size-context';
15
16
  import { useComposerActions } from './useComposerActions';
17
+ import { useComposerAttach } from './useComposerAttach';
16
18
  import type {
17
19
  ComposerAppearance,
20
+ ComposerAttachConfig,
18
21
  ComposerFooterProps,
19
22
  ComposerLayout,
20
23
  ComposerSize,
@@ -29,7 +32,16 @@ export interface ComposerProps {
29
32
  composer: UseChatComposerReturn;
30
33
  placeholder?: string;
31
34
  disabled?: boolean;
35
+ /** Render the paperclip attach button. The composer ships a built-in
36
+ * file picker — paperclip, `+` menu, drag-drop and Ctrl+V paste all
37
+ * funnel through one validated pipeline. */
32
38
  showAttachmentButton?: boolean;
39
+ /** Tune the built-in attach pipeline — accepted types, size / count
40
+ * caps, paste, and the optional upload transport. */
41
+ attach?: ComposerAttachConfig;
42
+ /** Override the built-in file picker. When set, the paperclip and the
43
+ * `+`-menu attach item call this instead of opening the native picker
44
+ * — for hosts that drive their own (e.g. a Wails native dialog). */
33
45
  onPickFiles?: () => void;
34
46
  className?: string;
35
47
  textareaClassName?: string;
@@ -130,6 +142,7 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
130
142
  placeholder = 'Type a message...',
131
143
  disabled,
132
144
  showAttachmentButton = false,
145
+ attach,
133
146
  onPickFiles,
134
147
  className,
135
148
  textareaClassName,
@@ -220,13 +233,60 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
220
233
  editable.focus();
221
234
  };
222
235
 
236
+ // ── Attach pipeline ────────────────────────────────────────────────────
237
+ // The built-in file picker is mounted whenever the host renders the
238
+ // paperclip OR passes an `attach` config. `onPickFiles` (if given)
239
+ // overrides it — a host driving its own picker (e.g. a Wails dialog).
240
+ const attachEnabled = showAttachmentButton || attach != null;
241
+ const attachHandle = useComposerAttach({
242
+ composer,
243
+ config: attach ?? {},
244
+ disabled: isDisabled,
245
+ pasteScopeRef: surfaceRef,
246
+ });
247
+ // The resolved click handler: host override wins, else the built-in
248
+ // picker. `undefined` when attaching is not enabled at all.
249
+ const pickFiles = !attachEnabled
250
+ ? undefined
251
+ : (onPickFiles ?? attachHandle.openPicker);
252
+
253
+ // Drag-drop onto the input surface — same validated path as the picker.
254
+ const [isDragging, setIsDragging] = useState(false);
255
+ const dragDepth = useRef(0);
256
+ const handleDragEnter = (e: React.DragEvent) => {
257
+ if (!attachEnabled || isDisabled) return;
258
+ if (!e.dataTransfer.types.includes('Files')) return;
259
+ e.preventDefault();
260
+ dragDepth.current += 1;
261
+ setIsDragging(true);
262
+ };
263
+ const handleDragOver = (e: React.DragEvent) => {
264
+ if (attachEnabled && !isDisabled && e.dataTransfer.types.includes('Files')) {
265
+ e.preventDefault();
266
+ }
267
+ };
268
+ const handleDragLeave = (e: React.DragEvent) => {
269
+ if (!attachEnabled || isDisabled) return;
270
+ e.preventDefault();
271
+ dragDepth.current = Math.max(0, dragDepth.current - 1);
272
+ if (dragDepth.current === 0) setIsDragging(false);
273
+ };
274
+ const handleDrop = (e: React.DragEvent) => {
275
+ if (!attachEnabled || isDisabled) return;
276
+ e.preventDefault();
277
+ dragDepth.current = 0;
278
+ setIsDragging(false);
279
+ const dropped = Array.from(e.dataTransfer.files ?? []);
280
+ if (dropped.length > 0) attachHandle.attachFiles(dropped);
281
+ };
282
+
223
283
  // Merge built-in send/stop/attach descriptors with host arrays.
224
284
  const { actionsStart, actionsEnd } = useComposerActions({
225
285
  composer,
226
286
  isStreaming,
227
287
  isDisabled,
228
288
  showAttachmentButton,
229
- onPickFiles,
289
+ onPickFiles: pickFiles,
230
290
  actionsStart: composerSlots?.actionsStart,
231
291
  actionsEnd: composerSlots?.actionsEnd,
232
292
  onSend: () => void composer.submit(),
@@ -311,11 +371,19 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
311
371
  <AttachSlot
312
372
  disabled={isDisabled}
313
373
  size={size}
314
- onClick={() => onPickFiles?.()}
374
+ onClick={() => pickFiles?.()}
315
375
  {...slotProps?.attach}
316
376
  />
317
377
  ) : null;
318
378
 
379
+ // Hidden native file input — the picker the paperclip + `+` menu open.
380
+ // Rendered once per composer; only present when the pipeline is active
381
+ // and no host override is in play (an override owns its own picker).
382
+ const hiddenFileInput =
383
+ attachEnabled && !onPickFiles ? (
384
+ <input {...attachHandle.inputProps} />
385
+ ) : null;
386
+
319
387
  // ── Footer ─────────────────────────────────────────────────────────────
320
388
  const footerNode =
321
389
  footer === false ? null : (
@@ -331,25 +399,53 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
331
399
  (composer.attachments.length > 0 ? (
332
400
  <Attachments
333
401
  attachments={composer.attachments}
334
- onRemove={(a) => composer.removeAttachment(a.id)}
402
+ // Route through the attach pipeline so a removed attachment's
403
+ // object-URL is revoked; falls back to a plain remove when the
404
+ // pipeline is not mounted (host-managed attachments).
405
+ onRemove={(a) =>
406
+ attachEnabled
407
+ ? attachHandle.removeAttachment(a.id)
408
+ : composer.removeAttachment(a.id)
409
+ }
335
410
  />
336
411
  ) : null);
337
412
 
338
413
  // ── Inline (compact single-row) layout ─────────────────────────────────
414
+ // Drag-drop handlers shared by both layouts. No-op when attaching is
415
+ // disabled — the handlers themselves early-return.
416
+ const dragProps = {
417
+ onDragEnter: handleDragEnter,
418
+ onDragOver: handleDragOver,
419
+ onDragLeave: handleDragLeave,
420
+ onDrop: handleDrop,
421
+ };
422
+ // A faint accent wash + ring while a file is dragged over the composer.
423
+ const dragOverlay =
424
+ isDragging ? (
425
+ <div
426
+ aria-hidden
427
+ className="pointer-events-none absolute inset-0 z-10 rounded-[inherit] bg-primary/5 ring-2 ring-inset ring-primary/40"
428
+ />
429
+ ) : null;
430
+
339
431
  // Start cluster sits left of the textarea, end cluster right of it —
340
432
  // `[📎][actionsStart] [textarea] [actionsEnd][🎙][▶]` (§3.6).
341
433
  if (layout === 'inline') {
342
434
  return (
343
435
  <ComposerSizeProvider value={size}>
436
+ <AttachProvider value={attachHandle}>
344
437
  <div
345
438
  ref={ref}
439
+ {...dragProps}
346
440
  className={cn(
347
- 'border-t border-border bg-background/95',
441
+ 'relative border-t border-border bg-background/95',
348
442
  sz.containerPadding,
349
443
  ap.containerPadding,
350
444
  className,
351
445
  )}
352
446
  >
447
+ {hiddenFileInput}
448
+ {dragOverlay}
353
449
  {trayNode ? <div className="mb-1.5">{trayNode}</div> : null}
354
450
  <div
355
451
  className={cn(
@@ -385,6 +481,7 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
385
481
  </div>
386
482
  {footerNode}
387
483
  </div>
484
+ </AttachProvider>
388
485
  </ComposerSizeProvider>
389
486
  );
390
487
  }
@@ -453,19 +550,24 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
453
550
 
454
551
  return (
455
552
  <ComposerSizeProvider value={size}>
553
+ <AttachProvider value={attachHandle}>
456
554
  <div
457
555
  ref={ref}
556
+ {...dragProps}
458
557
  className={cn(
459
- 'border-t border-border bg-background/95',
558
+ 'relative border-t border-border bg-background/95',
460
559
  sz.containerPadding,
461
560
  ap.containerPadding,
462
561
  className,
463
562
  )}
464
563
  >
564
+ {hiddenFileInput}
565
+ {dragOverlay}
465
566
  {trayNode ? <div className="mb-2">{trayNode}</div> : null}
466
567
  {surfaceWrapper}
467
568
  {footerNode}
468
569
  </div>
570
+ </AttachProvider>
469
571
  </ComposerSizeProvider>
470
572
  );
471
573
  });
@@ -1,17 +1,32 @@
1
1
  'use client';
2
2
 
3
- import { Plus } from 'lucide-react';
3
+ import { Paperclip, Plus } from 'lucide-react';
4
4
 
5
5
  import { MenuBuilder, type MenuItem } from '@djangocfg/ui-core/components';
6
6
  import { cn } from '@djangocfg/ui-core/lib';
7
7
 
8
+ import { useComposerAttachContext } from './AttachContext';
8
9
  import { BUTTON_SIZE } from './ComposerButton';
9
10
  import { useResolvedComposerSize } from './size-context';
10
11
  import type { ComposerSize } from './types';
11
12
 
13
+ /**
14
+ * Built-in "Attach" menu item config. When `attachItem` is set, the
15
+ * menu button prepends a row wired to the composer's attach pipeline —
16
+ * the SAME `openPicker` the paperclip button uses. Pass `true` for
17
+ * defaults, or an object to override the label / icon.
18
+ */
19
+ export type ComposerMenuAttachItem =
20
+ | boolean
21
+ | { label?: string };
22
+
12
23
  export interface ComposerMenuButtonProps {
13
24
  /** Declarative menu tree (see `MenuBuilder`). */
14
25
  items: MenuItem[];
26
+ /** Prepend a built-in "Attach" row wired to the composer's attach
27
+ * pipeline. Requires an attach-enabled `<Composer>` ancestor;
28
+ * silently omitted otherwise. */
29
+ attachItem?: ComposerMenuAttachItem;
15
30
  /** Omit to inherit the composer's size. */
16
31
  size?: ComposerSize;
17
32
  disabled?: boolean;
@@ -30,15 +45,37 @@ export interface ComposerMenuButtonProps {
30
45
  */
31
46
  export function ComposerMenuButton({
32
47
  items,
48
+ attachItem,
33
49
  size,
34
50
  disabled,
35
51
  label = 'Add files and more',
36
52
  icon,
37
53
  }: ComposerMenuButtonProps) {
38
54
  const sz = BUTTON_SIZE[useResolvedComposerSize(size)];
55
+ const attach = useComposerAttachContext();
56
+
57
+ // Prepend the built-in "Attach" row when requested AND an attach
58
+ // pipeline is in scope — it reuses the exact same `openPicker` as the
59
+ // paperclip button, so both inputs are one unified path.
60
+ const attachLabel =
61
+ typeof attachItem === 'object' ? (attachItem.label ?? 'Attach files') : 'Attach files';
62
+ const resolvedItems: MenuItem[] =
63
+ attachItem && attach
64
+ ? [
65
+ {
66
+ kind: 'item',
67
+ id: '__composer_attach',
68
+ label: attachLabel,
69
+ icon: Paperclip,
70
+ disabled: attach.disabled,
71
+ onSelect: () => attach.openPicker(),
72
+ },
73
+ ...items,
74
+ ]
75
+ : items;
39
76
 
40
77
  return (
41
- <MenuBuilder items={items} side="top" align="start">
78
+ <MenuBuilder items={resolvedItems} side="top" align="start">
42
79
  <button
43
80
  type="button"
44
81
  disabled={disabled}
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `File → ChatAttachment` — the engine-side producer that was missing.
3
+ *
4
+ * Before this, every host re-implemented the conversion (see the fake
5
+ * stub in `apps/storybook/.../SpeechAndAttachments`). It lives here so
6
+ * the picker, drag-drop and paste paths all mint identical attachments.
7
+ */
8
+
9
+ import { createId } from '../core/ids';
10
+ import { getAssetTypeFromMime } from '../../Uploader';
11
+ import type { ChatAttachment } from '../types';
12
+
13
+ /** Map a MIME type to the `ChatAttachment.type` discriminant. */
14
+ function attachmentTypeFromMime(mime: string): ChatAttachment['type'] {
15
+ if (mime.startsWith('image/')) return 'image';
16
+ if (mime.startsWith('audio/')) return 'audio';
17
+ if (mime.startsWith('video/')) return 'video';
18
+ // getAssetTypeFromMime returns 'document' for everything else; the
19
+ // ChatAttachment union has no 'document' member — collapse to 'file'.
20
+ const asset = getAssetTypeFromMime(mime);
21
+ return asset === 'document' ? 'file' : asset;
22
+ }
23
+
24
+ /**
25
+ * Convert a picked/dropped/pasted `File` into a `ChatAttachment`.
26
+ *
27
+ * The `url` is an object-URL (`URL.createObjectURL`) — instantly usable
28
+ * for previews. When a host uploads the file via `uploadFn`, it replaces
29
+ * `url` with the remote URL and flips `status` to `'ready'`.
30
+ *
31
+ * `status` defaults to `'ready'`: with no `uploadFn` the attachment is
32
+ * immediately usable (current behaviour). The attach pipeline overrides
33
+ * it to `'uploading'` when an upload is in flight.
34
+ */
35
+ export function fileToAttachment(file: File): ChatAttachment {
36
+ const mime = file.type || 'application/octet-stream';
37
+ return {
38
+ id: createId('att'),
39
+ type: attachmentTypeFromMime(mime),
40
+ url: URL.createObjectURL(file),
41
+ name: file.name,
42
+ mimeType: mime,
43
+ sizeBytes: file.size,
44
+ status: 'ready',
45
+ };
46
+ }
47
+
48
+ /** Revoke an attachment's object-URL, if it holds one. Call on remove. */
49
+ export function revokeAttachmentUrl(attachment: Pick<ChatAttachment, 'url'>): void {
50
+ if (attachment.url.startsWith('blob:')) {
51
+ URL.revokeObjectURL(attachment.url);
52
+ }
53
+ }
@@ -9,7 +9,11 @@ export {
9
9
  type ComposerAppearance,
10
10
  } from './Composer';
11
11
  export { ComposerButton, BUTTON_SIZE, type ComposerButtonProps } from './ComposerButton';
12
- export { ComposerMenuButton, type ComposerMenuButtonProps } from './ComposerMenuButton';
12
+ export {
13
+ ComposerMenuButton,
14
+ type ComposerMenuButtonProps,
15
+ type ComposerMenuAttachItem,
16
+ } from './ComposerMenuButton';
13
17
  export { ComposerRichTextarea, type ComposerRichTextareaProps } from './ComposerRichTextarea';
14
18
  export { ComposerActionBar } from './ComposerActionBar';
15
19
  export { ComposerToolPill, type ComposerToolPillProps } from './ComposerToolPill';
@@ -30,6 +34,12 @@ export {
30
34
  type UseComposerActionsParams,
31
35
  type ComposerActionClusters,
32
36
  } from './useComposerActions';
37
+ export {
38
+ useComposerAttach,
39
+ type UseComposerAttachParams,
40
+ } from './useComposerAttach';
41
+ export { useComposerAttachContext } from './AttachContext';
42
+ export { fileToAttachment, revokeAttachmentUrl } from './fileToAttachment';
33
43
  export type {
34
44
  ComposerAction,
35
45
  ComposerActionVisibility,
@@ -42,4 +52,9 @@ export type {
42
52
  AttachButtonProps,
43
53
  ComposerTextareaProps,
44
54
  ActionBarProps,
55
+ ComposerAcceptType,
56
+ ComposerAttachConfig,
57
+ ComposerAttachHandle,
58
+ ComposerUploadFn,
59
+ ComposerUploadResult,
45
60
  } from './types';
@@ -3,6 +3,7 @@
3
3
  import type { ComponentType, ReactNode } from 'react';
4
4
 
5
5
  import type { UseChatComposerReturn } from '../hooks/useChatComposer';
6
+ import type { ChatAttachment } from '../types';
6
7
 
7
8
  /** Composer visual size — shared across the `<Composer>` API. */
8
9
  export type ComposerSize = 'sm' | 'md' | 'lg';
@@ -141,3 +142,73 @@ export interface ComposerFooterProps {
141
142
  size?: ComposerSize;
142
143
  className?: string;
143
144
  }
145
+
146
+ // ── Attach pipeline ─────────────────────────────────────────────────────────
147
+
148
+ /** Asset categories the picker accepts. Maps to MIME prefixes / extensions. */
149
+ export type ComposerAcceptType = 'image' | 'audio' | 'video' | 'document';
150
+
151
+ /**
152
+ * Result a host's `uploadFn` resolves to — the remote location the
153
+ * attachment's `url` is rewritten to once the upload completes.
154
+ */
155
+ export interface ComposerUploadResult {
156
+ url: string;
157
+ thumbnailUrl?: string;
158
+ }
159
+
160
+ /**
161
+ * Optional upload transport. The single environment-specific seam:
162
+ * - omitted → attachments stay local (`blob:` object-URL, `status:'ready'`).
163
+ * - web → multipart POST to a CDN.
164
+ * - Wails → a Wails-bound upload method.
165
+ *
166
+ * `onProgress` receives 0..1; call it to drive the staging-tray progress.
167
+ */
168
+ export type ComposerUploadFn = (
169
+ file: File,
170
+ onProgress: (fraction: number) => void,
171
+ ) => Promise<ComposerUploadResult>;
172
+
173
+ /**
174
+ * Declarative config for the built-in attach pipeline. When present (or
175
+ * when `showAttachmentButton` is set), the composer mounts its own file
176
+ * picker — paperclip, `+` menu, drag-drop and paste all funnel through it.
177
+ */
178
+ export interface ComposerAttachConfig {
179
+ /** Accepted asset categories. Default: all four. */
180
+ accept?: ComposerAcceptType[];
181
+ /** Max files per message. Falls back to the composer's `maxAttachments`. */
182
+ maxFiles?: number;
183
+ /** Per-file size cap in bytes. Default: unlimited. */
184
+ maxSizeBytes?: number;
185
+ /** Allow selecting more than one file at once. Default `true`. */
186
+ multiple?: boolean;
187
+ /** Upload transport — see {@link ComposerUploadFn}. */
188
+ uploadFn?: ComposerUploadFn;
189
+ /** Enable Ctrl+V / Cmd+V paste-to-attach. Default `true`. */
190
+ pasteEnabled?: boolean;
191
+ /** Called when a file is rejected (size / type / count). */
192
+ onReject?: (file: File, reason: 'size' | 'type' | 'count') => void;
193
+ }
194
+
195
+ /** Imperative handle the attach pipeline exposes to the composer + menu. */
196
+ export interface ComposerAttachHandle {
197
+ /** Open the native file picker. */
198
+ openPicker: () => void;
199
+ /** Funnel already-resolved files (drop / paste) through validation. */
200
+ attachFiles: (files: File[]) => void;
201
+ /** Remove a staged attachment, revoking its object-URL first. */
202
+ removeAttachment: (id: string) => void;
203
+ /** True when attaching is unavailable (disabled / at file cap). */
204
+ disabled: boolean;
205
+ /** Hidden `<input type=file>` props — the composer renders this node. */
206
+ inputProps: {
207
+ ref: React.RefObject<HTMLInputElement | null>;
208
+ type: 'file';
209
+ accept: string;
210
+ multiple: boolean;
211
+ hidden: true;
212
+ onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
213
+ };
214
+ }