@sybilion/uilib 1.3.9 → 1.3.11

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 (74) hide show
  1. package/dist/esm/components/ui/ChartAreaInteractive/ChartAreaInteractive.js +4 -2
  2. package/dist/esm/components/ui/Chat/ChatChrome/ChatChrome.js +52 -17
  3. package/dist/esm/components/ui/Chat/ChatChrome/ChatChrome.styl.js +2 -2
  4. package/dist/esm/components/ui/Chat/ChatMessage/ChatMessage.styl.js +2 -2
  5. package/dist/esm/components/ui/Chat/ChatMessage/UserCsvAttachmentBubble.js +3 -4
  6. package/dist/esm/components/ui/Chat/ChatPrompt/ChatPrompt.js +6 -4
  7. package/dist/esm/components/ui/Chat/ChatPrompt/ChatPrompt.styl.js +2 -2
  8. package/dist/esm/components/ui/Chat/ChatPrompt/ChatPromptAttachments.js +11 -0
  9. package/dist/esm/components/ui/Chat/ChatSheet/ChatSelector.js +1 -1
  10. package/dist/esm/components/ui/Chat/ChatSheet/useChatPanelChromeModel.js +4 -1
  11. package/dist/esm/components/ui/Chat/chatAttachmentAccept.js +54 -0
  12. package/dist/esm/components/ui/Chat/chatAttachmentExtract.js +26 -0
  13. package/dist/esm/components/ui/Chat/chatPdfExtract.js +31 -0
  14. package/dist/esm/components/ui/DropZone/DropZone.js +50 -21
  15. package/dist/esm/components/ui/DropZone/DropZone.styl.js +2 -2
  16. package/dist/esm/components/ui/FileChip/FileChip.js +26 -0
  17. package/dist/esm/components/ui/FileChip/FileChip.styl.js +7 -0
  18. package/dist/esm/index.js +2 -0
  19. package/dist/esm/types/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.d.ts +1 -1
  20. package/dist/esm/types/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.types.d.ts +2 -0
  21. package/dist/esm/types/src/components/ui/Chat/Chat.types.d.ts +10 -1
  22. package/dist/esm/types/src/components/ui/Chat/ChatChrome/ChatChrome.d.ts +1 -1
  23. package/dist/esm/types/src/components/ui/Chat/ChatChrome/ChatChrome.types.d.ts +9 -2
  24. package/dist/esm/types/src/components/ui/Chat/ChatChrome/index.d.ts +1 -1
  25. package/dist/esm/types/src/components/ui/Chat/ChatPrompt/ChatPrompt.d.ts +1 -1
  26. package/dist/esm/types/src/components/ui/Chat/ChatPrompt/ChatPromptAttachments.d.ts +8 -0
  27. package/dist/esm/types/src/components/ui/Chat/ChatSheet/useChatPanelChromeModel.d.ts +7 -1
  28. package/dist/esm/types/src/components/ui/Chat/chatAttachmentAccept.d.ts +8 -0
  29. package/dist/esm/types/src/components/ui/Chat/chatAttachmentExtract.d.ts +2 -0
  30. package/dist/esm/types/src/components/ui/Chat/chatPdfExtract.d.ts +2 -0
  31. package/dist/esm/types/src/components/ui/Chat/index.d.ts +2 -1
  32. package/dist/esm/types/src/components/ui/DropZone/DropZone.d.ts +2 -0
  33. package/dist/esm/types/src/components/ui/FileChip/FileChip.d.ts +2 -0
  34. package/dist/esm/types/src/components/ui/FileChip/FileChip.types.d.ts +10 -0
  35. package/dist/esm/types/src/components/ui/FileChip/index.d.ts +2 -0
  36. package/dist/esm/types/src/docs/pages/ChatAttachmentsDropzonePage.d.ts +1 -0
  37. package/dist/esm/types/src/docs/pages/FileChipPage.d.ts +1 -0
  38. package/dist/esm/types/src/index.d.ts +1 -0
  39. package/package.json +2 -1
  40. package/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.tsx +4 -1
  41. package/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.types.ts +2 -0
  42. package/src/components/ui/Chat/Chat.types.ts +11 -1
  43. package/src/components/ui/Chat/ChatChrome/ChatChrome.styl +20 -0
  44. package/src/components/ui/Chat/ChatChrome/ChatChrome.styl.d.ts +2 -0
  45. package/src/components/ui/Chat/ChatChrome/ChatChrome.tsx +88 -4
  46. package/src/components/ui/Chat/ChatChrome/ChatChrome.types.ts +18 -2
  47. package/src/components/ui/Chat/ChatChrome/index.ts +1 -0
  48. package/src/components/ui/Chat/ChatMessage/ChatMessage.styl +0 -56
  49. package/src/components/ui/Chat/ChatMessage/ChatMessage.styl.d.ts +0 -5
  50. package/src/components/ui/Chat/ChatMessage/UserCsvAttachmentBubble.tsx +6 -15
  51. package/src/components/ui/Chat/ChatPrompt/ChatPrompt.styl +11 -15
  52. package/src/components/ui/Chat/ChatPrompt/ChatPrompt.styl.d.ts +2 -1
  53. package/src/components/ui/Chat/ChatPrompt/ChatPrompt.tsx +17 -8
  54. package/src/components/ui/Chat/ChatPrompt/ChatPromptAttachments.tsx +34 -0
  55. package/src/components/ui/Chat/ChatSheet/ChatSelector.tsx +12 -11
  56. package/src/components/ui/Chat/ChatSheet/ChatSheet.styl.d.ts +13 -13
  57. package/src/components/ui/Chat/ChatSheet/useChatPanelChromeModel.tsx +14 -0
  58. package/src/components/ui/Chat/chat-preset-utils.ts +4 -1
  59. package/src/components/ui/Chat/chatAttachmentAccept.ts +70 -0
  60. package/src/components/ui/Chat/chatAttachmentExtract.ts +33 -0
  61. package/src/components/ui/Chat/chatPdfExtract.ts +37 -0
  62. package/src/components/ui/Chat/index.ts +5 -0
  63. package/src/components/ui/DropZone/DropZone.styl +24 -0
  64. package/src/components/ui/DropZone/DropZone.styl.d.ts +3 -0
  65. package/src/components/ui/DropZone/DropZone.tsx +77 -24
  66. package/src/components/ui/FileChip/FileChip.styl +108 -0
  67. package/src/components/ui/FileChip/FileChip.styl.d.ts +12 -0
  68. package/src/components/ui/FileChip/FileChip.tsx +93 -0
  69. package/src/components/ui/FileChip/FileChip.types.ts +11 -0
  70. package/src/components/ui/FileChip/index.ts +2 -0
  71. package/src/docs/pages/ChatAttachmentsDropzonePage.tsx +162 -0
  72. package/src/docs/pages/FileChipPage.tsx +50 -0
  73. package/src/docs/registry.ts +12 -0
  74. package/src/index.ts +1 -0
@@ -19,21 +19,6 @@ INPUT_MAX_HEIGHT = 200px
19
19
  gap var(--p-3)
20
20
  width 100%
21
21
 
22
- .notice
23
- position absolute
24
- top calc(-1 * var(--p-12))
25
- left 0
26
- right 0
27
- margin-bottom var(--p-1)
28
-
29
- font-size var(--text-xs)
30
- text-align center
31
- color var(--muted-foreground)
32
- pointer-events none
33
-
34
- @media (max-width MOBILE)
35
- font-size 10px
36
-
37
22
  .input
38
23
  flex 1
39
24
  min-width 0
@@ -79,3 +64,14 @@ INPUT_MAX_HEIGHT = 200px
79
64
  .attachButton
80
65
  background-color var(--page-color)
81
66
  box-shadow 0 0 20px var(--background)
67
+
68
+ .attachments
69
+ display flex
70
+ flex-wrap wrap
71
+ gap var(--p-2)
72
+ margin-bottom var(--p-2)
73
+
74
+ .attachmentItem
75
+ flex 1 1 300px
76
+ max-width 300px
77
+ min-width 0
@@ -2,9 +2,10 @@
2
2
  // Please do not change this file!
3
3
  interface CssExports {
4
4
  'attachButton': string;
5
+ 'attachmentItem': string;
6
+ 'attachments': string;
5
7
  'composer': string;
6
8
  'input': string;
7
- 'notice': string;
8
9
  'root': string;
9
10
  'submitColumn': string;
10
11
  }
@@ -9,6 +9,7 @@ import { Input } from '../../Input';
9
9
  import type { ChatPromptProps } from '../Chat.types';
10
10
  import { syncChatPromptTextareaHeight } from './ChatPrompt.helpers';
11
11
  import S from './ChatPrompt.styl';
12
+ import { ChatPromptAttachments } from './ChatPromptAttachments';
12
13
 
13
14
  export function ChatPrompt({
14
15
  onSubmit,
@@ -16,7 +17,9 @@ export function ChatPrompt({
16
17
  className,
17
18
  footer,
18
19
  prefillMessage,
19
- showNotice = true,
20
+ attachments = [],
21
+ onRemoveAttachment,
22
+ disabled = false,
20
23
  }: ChatPromptProps) {
21
24
  const [message, setMessage] = useState('');
22
25
  const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -35,10 +38,11 @@ export function ChatPrompt({
35
38
 
36
39
  const handleSubmit = (e: FormEvent | KeyboardEvent) => {
37
40
  const trimmedMessage = message.trim();
41
+ const hasAttachments = attachments.length > 0;
38
42
 
39
- if (trimmedMessage) {
43
+ if (trimmedMessage || hasAttachments) {
40
44
  e.preventDefault();
41
- onSubmit(trimmedMessage);
45
+ onSubmit(trimmedMessage, hasAttachments ? attachments : undefined);
42
46
  setMessage('');
43
47
  }
44
48
  };
@@ -56,11 +60,12 @@ export function ChatPrompt({
56
60
 
57
61
  return (
58
62
  <form onSubmit={handleSubmit} className={cn(S.root, className)}>
63
+ <ChatPromptAttachments
64
+ attachments={attachments}
65
+ onRemove={index => onRemoveAttachment?.(index)}
66
+ disabled={disabled}
67
+ />
59
68
  <div className={S.composer}>
60
- {showNotice ? (
61
- <div className={S.notice}>Forecast Assistant can make mistakes.</div>
62
- ) : null}
63
-
64
69
  <Input
65
70
  ref={inputRef}
66
71
  type="textarea"
@@ -72,7 +77,11 @@ export function ChatPrompt({
72
77
  />
73
78
 
74
79
  <div className={S.submitColumn}>
75
- <Button type="submit" size="sm" disabled={!message.trim()}>
80
+ <Button
81
+ type="submit"
82
+ size="sm"
83
+ disabled={disabled || (!message.trim() && attachments.length === 0)}
84
+ >
76
85
  <SendHorizontalIcon size={16} />
77
86
  </Button>
78
87
  </div>
@@ -0,0 +1,34 @@
1
+ import { FileChip } from '#uilib/components/ui/FileChip';
2
+
3
+ import type { ChatAttachmentDropItem } from '../Chat.types';
4
+ import S from './ChatPrompt.styl';
5
+
6
+ type ChatPromptAttachmentsProps = {
7
+ attachments: ChatAttachmentDropItem[];
8
+ onRemove: (index: number) => void;
9
+ disabled?: boolean;
10
+ };
11
+
12
+ export function ChatPromptAttachments({
13
+ attachments,
14
+ onRemove,
15
+ disabled = false,
16
+ }: ChatPromptAttachmentsProps) {
17
+ if (attachments.length === 0) return null;
18
+
19
+ return (
20
+ <div className={S.attachments}>
21
+ {attachments.map((item, index) => (
22
+ <FileChip
23
+ key={`${item.file.name}-${index}`}
24
+ className={S.attachmentItem}
25
+ name={item.file.name}
26
+ format={item.kind === 'pdf' ? 'pdf' : 'text'}
27
+ hint={item.kind === 'pdf' ? 'PDF' : 'Text file'}
28
+ onRemove={() => onRemove(index)}
29
+ disabled={disabled}
30
+ />
31
+ ))}
32
+ </div>
33
+ );
34
+ }
@@ -87,17 +87,18 @@ export function ChatSelector({
87
87
  </SelectContent>
88
88
  </Select>
89
89
  </div>
90
- <Button
91
- type="button"
92
- variant="ghost"
93
- size="sm"
94
- className={S.deleteBtn}
95
- aria-label="Delete chat"
96
- disabled={!currentChatId || chats.length === 0}
97
- onClick={handleDeleteChat}
98
- >
99
- <Trash2Icon size={16} />
100
- </Button>
90
+ {currentChatId && chats.length > 0 && (
91
+ <Button
92
+ type="button"
93
+ variant="ghost"
94
+ size="sm"
95
+ className={S.deleteBtn}
96
+ aria-label="Delete chat"
97
+ onClick={handleDeleteChat}
98
+ >
99
+ <Trash2Icon size={16} />
100
+ </Button>
101
+ )}
101
102
  </div>
102
103
  );
103
104
  }
@@ -1,19 +1,19 @@
1
1
  // This file is automatically generated.
2
2
  // Please do not change this file!
3
3
  interface CssExports {
4
- 'branchBtnWrap': string;
5
- 'branchRow': string;
6
- 'chatResizeHandle': string;
7
- 'content': string;
8
- 'footer': string;
9
- 'loader': string;
10
- 'panelClose': string;
11
- 'panelHeader': string;
12
- 'panelRoot': string;
13
- 'scroll': string;
14
- 'scrollInner': string;
15
- 'scrollWrapper': string;
16
- 'scrollbar': string;
4
+ branchBtnWrap: string;
5
+ branchRow: string;
6
+ chatResizeHandle: string;
7
+ content: string;
8
+ footer: string;
9
+ loader: string;
10
+ panelClose: string;
11
+ panelHeader: string;
12
+ panelRoot: string;
13
+ scroll: string;
14
+ scrollInner: string;
15
+ scrollWrapper: string;
16
+ scrollbar: string;
17
17
  }
18
18
  export const cssExports: CssExports;
19
19
  export default cssExports;
@@ -39,6 +39,7 @@ import { ScrollRef } from '@homecode/ui';
39
39
  import { useSidebar } from '../../Sidebar/Sidebar';
40
40
  import { Chat } from '../Chat';
41
41
  import type { ChatChromeProps } from '../ChatChrome';
42
+ import type { ChatAttachmentDropItem } from '../ChatChrome/ChatChrome.types';
42
43
  import type { ChatEmptyStateProps } from '../ChatEmptyState/ChatEmptyState.types';
43
44
 
44
45
  export type UseChatPanelChromeModelInput = {
@@ -56,6 +57,13 @@ export type UseChatPanelChromeModelInput = {
56
57
  renderMessageChart?: () => React.ReactNode;
57
58
  /** Forwarded to `ChatChrome` when the thread is empty. */
58
59
  emptyState?: ChatEmptyStateProps;
60
+ /** MIME types / extensions for text-only chat attachments (filtered by uilib allowlist). */
61
+ allowedAttachments?: readonly string[];
62
+ /** When true, PDF drops are accepted and parsed to plain text. */
63
+ allowPdfAttachments?: boolean;
64
+ onAttachmentsDropped?: (
65
+ items: ChatAttachmentDropItem[],
66
+ ) => void | Promise<void>;
59
67
  };
60
68
 
61
69
  export type UseChatPanelChromeModelResult = {
@@ -101,6 +109,9 @@ export function useChatPanelChromeModel({
101
109
  onGenerateDashboard,
102
110
  renderMessageChart,
103
111
  emptyState,
112
+ allowedAttachments,
113
+ allowPdfAttachments,
114
+ onAttachmentsDropped,
104
115
  }: UseChatPanelChromeModelInput): UseChatPanelChromeModelResult {
105
116
  const effectiveScopeId = scopeId ?? NO_SCOPE_FALLBACK;
106
117
  const isMobile = useIsMobile();
@@ -998,6 +1009,9 @@ export function useChatPanelChromeModel({
998
1009
  onChatDeleted: endLocalDemoFlow,
999
1010
  promptPrefill: promptLinkPrefill,
1000
1011
  emptyState,
1012
+ allowedAttachments,
1013
+ allowPdfAttachments,
1014
+ onAttachmentsDropped,
1001
1015
  };
1002
1016
 
1003
1017
  const toggleOpen = () => onOpenChange(!isOpen);
@@ -11,7 +11,10 @@ export function normalizePresetMatchText(s: string): string {
11
11
  return s.trim().normalize('NFC');
12
12
  }
13
13
 
14
- function presetMatchesUserText(presetText: string, userTextNorm: string): boolean {
14
+ function presetMatchesUserText(
15
+ presetText: string,
16
+ userTextNorm: string,
17
+ ): boolean {
15
18
  const presetNorm = normalizePresetMatchText(presetText);
16
19
  if (!presetNorm) return false;
17
20
  if (userTextNorm === presetNorm) return true;
@@ -0,0 +1,70 @@
1
+ /** MIME types and extensions accepted for chat text attachments. */
2
+ export const TEXT_ATTACHMENT_ACCEPT_PARTS = [
3
+ 'text/plain',
4
+ '.txt',
5
+ 'text/csv',
6
+ '.csv',
7
+ 'text/markdown',
8
+ '.md',
9
+ '.markdown',
10
+ 'application/json',
11
+ '.json',
12
+ 'text/html',
13
+ '.html',
14
+ '.htm',
15
+ 'text/xml',
16
+ 'application/xml',
17
+ '.xml',
18
+ 'text/yaml',
19
+ 'application/yaml',
20
+ 'application/x-yaml',
21
+ '.yaml',
22
+ '.yml',
23
+ 'text/tab-separated-values',
24
+ '.tsv',
25
+ 'text/calendar',
26
+ '.ics',
27
+ ] as const;
28
+
29
+ export const PDF_ATTACHMENT_ACCEPT_PARTS = ['application/pdf', '.pdf'] as const;
30
+
31
+ const TEXT_ATTACHMENT_ACCEPT_SET = new Set<string>(
32
+ TEXT_ATTACHMENT_ACCEPT_PARTS.map(part => part.toLowerCase()),
33
+ );
34
+
35
+ /** Keep only tokens from `parts` that appear in the text attachment allowlist. */
36
+ export function filterToTextAttachments(
37
+ parts: readonly string[] | undefined,
38
+ ): string[] {
39
+ if (!parts?.length) return [];
40
+ return parts.filter(part =>
41
+ TEXT_ATTACHMENT_ACCEPT_SET.has(part.trim().toLowerCase()),
42
+ );
43
+ }
44
+
45
+ export function buildAcceptAttr(
46
+ filteredTextParts: readonly string[],
47
+ allowPdf: boolean,
48
+ ): string {
49
+ const parts = [...filteredTextParts];
50
+ if (allowPdf) {
51
+ parts.push(...PDF_ATTACHMENT_ACCEPT_PARTS);
52
+ }
53
+ return parts.join(',');
54
+ }
55
+
56
+ export function isPdfFile(file: File): boolean {
57
+ const type = file.type.toLowerCase();
58
+ if (type === 'application/pdf') return true;
59
+ return file.name.toLowerCase().endsWith('.pdf');
60
+ }
61
+
62
+ export function isAttachmentsDropzoneEnabled(
63
+ allowedAttachments: readonly string[] | undefined,
64
+ allowPdfAttachments: boolean | undefined,
65
+ ): boolean {
66
+ return (
67
+ filterToTextAttachments(allowedAttachments).length > 0 ||
68
+ Boolean(allowPdfAttachments)
69
+ );
70
+ }
@@ -0,0 +1,33 @@
1
+ import type { ChatAttachmentDropItem } from './Chat.types';
2
+ import { isPdfFile } from './chatAttachmentAccept';
3
+ import { extractPdfFileToText } from './chatPdfExtract';
4
+
5
+ function readTextFile(file: File): Promise<string> {
6
+ return new Promise((resolve, reject) => {
7
+ const reader = new FileReader();
8
+ reader.onload = () => resolve(String(reader.result ?? ''));
9
+ reader.onerror = () =>
10
+ reject(reader.error ?? new Error(`Failed to read ${file.name}`));
11
+ reader.readAsText(file);
12
+ });
13
+ }
14
+
15
+ export async function extractChatAttachmentItems(
16
+ files: File[],
17
+ allowPdfAttachments: boolean,
18
+ ): Promise<ChatAttachmentDropItem[]> {
19
+ const items = await Promise.all(
20
+ files.map(async file => {
21
+ if (isPdfFile(file)) {
22
+ if (!allowPdfAttachments) return null;
23
+ const text = await extractPdfFileToText(file);
24
+ return { file, text, kind: 'pdf' as const };
25
+ }
26
+
27
+ const text = await readTextFile(file);
28
+ return { file, text, kind: 'text' as const };
29
+ }),
30
+ );
31
+
32
+ return items.filter((item): item is ChatAttachmentDropItem => item != null);
33
+ }
@@ -0,0 +1,37 @@
1
+ let workerConfigured = false;
2
+
3
+ async function configurePdfWorker(
4
+ pdfjs: typeof import('pdfjs-dist/legacy/build/pdf.mjs'),
5
+ ): Promise<void> {
6
+ if (workerConfigured) return;
7
+
8
+ const version = pdfjs.version ?? '4.10.38';
9
+ pdfjs.GlobalWorkerOptions.workerSrc = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${version}/legacy/build/pdf.worker.min.mjs`;
10
+ workerConfigured = true;
11
+ }
12
+
13
+ /** Best-effort plain text from PDF; pages separated with lightweight markdown headings. */
14
+ export async function extractPdfFileToText(file: File): Promise<string> {
15
+ const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs');
16
+ await configurePdfWorker(pdfjs);
17
+
18
+ const data = new Uint8Array(await file.arrayBuffer());
19
+ const doc = await pdfjs.getDocument({ data }).promise;
20
+ const pageTexts: string[] = [];
21
+
22
+ for (let pageNumber = 1; pageNumber <= doc.numPages; pageNumber += 1) {
23
+ const page = await doc.getPage(pageNumber);
24
+ const content = await page.getTextContent();
25
+ const pageText = content.items
26
+ .map(item => ('str' in item ? item.str : ''))
27
+ .join(' ')
28
+ .replace(/\s+/g, ' ')
29
+ .trim();
30
+
31
+ if (pageText) {
32
+ pageTexts.push(`## Page ${pageNumber}\n\n${pageText}`);
33
+ }
34
+ }
35
+
36
+ return pageTexts.join('\n\n');
37
+ }
@@ -5,6 +5,10 @@ export type {
5
5
  ChatChromeProps,
6
6
  ChatChromeResizeHandleConfig,
7
7
  } from './ChatChrome';
8
+ export {
9
+ TEXT_ATTACHMENT_ACCEPT_PARTS,
10
+ filterToTextAttachments,
11
+ } from './chatAttachmentAccept';
8
12
  export { ChatSheet } from './ChatSheet/ChatSheet';
9
13
  export { useChatPanelChromeModel } from './ChatSheet/useChatPanelChromeModel';
10
14
  export type { ChatSheetActions, ChatSheetProps } from './ChatSheet/ChatSheet';
@@ -17,6 +21,7 @@ export { ChatPrompt } from './ChatPrompt';
17
21
  export { ChatPresets } from './ChatPresets';
18
22
  export type {
19
23
  Chat as ChatType,
24
+ ChatAttachmentDropItem,
20
25
  ChatSendMessagePayload,
21
26
  ChatProps,
22
27
  ChatPreset as ChatPresetType,
@@ -5,6 +5,14 @@
5
5
  flex-direction column
6
6
  gap var(--p-2)
7
7
 
8
+ .rootContained
9
+ position relative
10
+ width 100%
11
+ height 100%
12
+
13
+ .rootDragging
14
+ pointer-events auto
15
+
8
16
  .dropArea
9
17
  position relative
10
18
  border 2px dashed var(--border)
@@ -42,8 +50,24 @@
42
50
  border-color var(--primary-color)
43
51
  background-color var(--page-color-alpha-800)
44
52
 
53
+ &.isDraggingContained
54
+ position absolute
55
+ top 0
56
+ left 0
57
+ width 100%
58
+ height 100%
59
+ z-index 1
60
+ display flex
61
+ align-items center
62
+ justify-content center
63
+ border-radius var(--p-4)
64
+
65
+ border-color var(--primary-color)
66
+ background-color var(--page-color-alpha-800)
67
+
45
68
  &:hover:not(.disabled)
46
69
  &.isDragging
70
+ &.isDraggingContained
47
71
  border 2px dashed var(--muted-border)
48
72
 
49
73
  :global(.dark) &
@@ -6,8 +6,11 @@ interface CssExports {
6
6
  'error': string;
7
7
  'fileInput': string;
8
8
  'isDragging': string;
9
+ 'isDraggingContained': string;
9
10
  'label': string;
10
11
  'root': string;
12
+ 'rootContained': string;
13
+ 'rootDragging': string;
11
14
  }
12
15
  export const cssExports: CssExports;
13
16
  export default cssExports;
@@ -1,3 +1,4 @@
1
+ import cn from 'classnames';
1
2
  import { useCallback, useEffect, useRef, useState } from 'react';
2
3
 
3
4
  import S from './DropZone.styl';
@@ -8,6 +9,8 @@ interface DropZoneBaseProps {
8
9
  error?: string | null;
9
10
  disabled?: boolean;
10
11
  ghost?: boolean;
12
+ /** When `container`, drag overlay fills the parent bounds instead of the viewport. */
13
+ overlayScope?: 'viewport' | 'container';
11
14
  id?: string;
12
15
  className?: string;
13
16
  }
@@ -59,39 +62,49 @@ export function DropZone(props: DropZoneProps) {
59
62
  error,
60
63
  disabled = false,
61
64
  ghost = false,
65
+ overlayScope = 'viewport',
62
66
  id,
63
67
  className,
64
68
  } = props;
65
69
  const [isDragging, setIsDragging] = useState(false);
70
+ const rootRef = useRef<HTMLDivElement>(null);
66
71
  const dropAreaRef = useRef<HTMLDivElement>(null);
67
72
  const inputId =
68
73
  id || `dropzone-file-input-${Math.random().toString(36).substr(2, 9)}`;
69
74
 
70
- const handleDrop = useCallback(
71
- (e: React.DragEvent<HTMLDivElement>) => {
72
- e.preventDefault();
73
- setIsDragging(false);
75
+ const dropProcessedRef = useRef(false);
74
76
 
75
- if (disabled) return;
77
+ const emitDroppedFiles = useCallback(
78
+ (files: FileList) => {
79
+ if (disabled || dropProcessedRef.current || files.length === 0) return;
80
+
81
+ const list = Array.from(files).filter(f => matchesAccept(f, accept));
82
+ if (list.length === 0) return;
76
83
 
77
- const list = Array.from(e.dataTransfer.files).filter(f =>
78
- matchesAccept(f, accept),
79
- );
84
+ dropProcessedRef.current = true;
80
85
 
81
86
  if (props.multiple === true) {
82
- if (list.length > 0) {
83
- props.onFiles(list);
84
- }
87
+ props.onFiles(list);
85
88
  } else {
86
- const file = list[0];
87
- if (file) {
88
- props.onFile(file);
89
- }
89
+ props.onFile(list[0]!);
90
90
  }
91
91
  },
92
92
  [accept, disabled, props],
93
93
  );
94
94
 
95
+ const handleDrop = useCallback(
96
+ (e: React.DragEvent<HTMLDivElement>) => {
97
+ e.preventDefault();
98
+ e.stopPropagation();
99
+ setIsDragging(false);
100
+
101
+ if (disabled) return;
102
+
103
+ emitDroppedFiles(e.dataTransfer.files);
104
+ },
105
+ [disabled, emitDroppedFiles],
106
+ );
107
+
95
108
  const handleFileInput = useCallback(
96
109
  (e: React.ChangeEvent<HTMLInputElement>) => {
97
110
  if (disabled) return;
@@ -116,12 +129,20 @@ export function DropZone(props: DropZoneProps) {
116
129
  );
117
130
 
118
131
  useEffect(() => {
119
- // Find closest dialog or fall back to body
120
- const dialog = dropAreaRef.current?.closest('[role="dialog"]');
121
- const targetElement = (dialog as HTMLElement) || document.body;
132
+ const root = rootRef.current;
133
+ if (!root) return;
134
+
135
+ const targetElement =
136
+ overlayScope === 'container'
137
+ ? root.parentElement
138
+ : ((root.closest('[role="dialog"]') as HTMLElement | null) ??
139
+ document.body);
140
+
141
+ if (!targetElement) return;
122
142
 
123
143
  const handleGlobalDragOver = (e: DragEvent) => {
124
144
  e.preventDefault();
145
+ dropProcessedRef.current = false;
125
146
  setIsDragging(true);
126
147
  };
127
148
 
@@ -136,30 +157,62 @@ export function DropZone(props: DropZoneProps) {
136
157
  }
137
158
  };
138
159
 
139
- const handleGlobalDrop = () => {
160
+ const handleGlobalDropPrevent = (e: DragEvent) => {
161
+ e.preventDefault();
162
+ };
163
+
164
+ const handleGlobalDropFinalize = (e: DragEvent) => {
165
+ if (overlayScope === 'container' && e.dataTransfer?.files?.length) {
166
+ emitDroppedFiles(e.dataTransfer.files);
167
+ }
140
168
  setIsDragging(false);
141
169
  };
142
170
 
143
171
  targetElement.addEventListener('dragover', handleGlobalDragOver);
144
172
  targetElement.addEventListener('dragleave', handleGlobalDragLeave);
145
- targetElement.addEventListener('drop', handleGlobalDrop);
173
+ targetElement.addEventListener('drop', handleGlobalDropPrevent, true);
174
+ targetElement.addEventListener('drop', handleGlobalDropFinalize);
146
175
 
147
176
  return () => {
148
177
  targetElement.removeEventListener('dragover', handleGlobalDragOver);
149
178
  targetElement.removeEventListener('dragleave', handleGlobalDragLeave);
150
- targetElement.removeEventListener('drop', handleGlobalDrop);
179
+ targetElement.removeEventListener('drop', handleGlobalDropPrevent, true);
180
+ targetElement.removeEventListener('drop', handleGlobalDropFinalize);
151
181
  };
152
- }, []);
182
+ }, [emitDroppedFiles, overlayScope]);
153
183
 
154
184
  const shouldShowDropArea = !ghost || isDragging;
155
185
  const multiple = props.multiple === true;
156
186
 
187
+ const isContainerOverlay = overlayScope === 'container';
188
+
157
189
  return (
158
- <div className={`${S.root} ${className || ''}`}>
190
+ <div
191
+ ref={rootRef}
192
+ className={cn(
193
+ S.root,
194
+ isContainerOverlay && S.rootContained,
195
+ isDragging && isContainerOverlay && S.rootDragging,
196
+ className,
197
+ )}
198
+ style={
199
+ isContainerOverlay
200
+ ? { pointerEvents: isDragging ? 'auto' : 'none' }
201
+ : undefined
202
+ }
203
+ >
159
204
  {shouldShowDropArea && (
160
205
  <div
161
206
  ref={dropAreaRef}
162
- className={`${S.dropArea} ${isDragging ? S.isDragging : ''} ${disabled ? S.disabled : ''}`}
207
+ className={cn(
208
+ S.dropArea,
209
+ isDragging
210
+ ? isContainerOverlay
211
+ ? S.isDraggingContained
212
+ : S.isDragging
213
+ : '',
214
+ disabled ? S.disabled : '',
215
+ )}
163
216
  onDrop={handleDrop}
164
217
  >
165
218
  <input