@urbicon-ui/blocks 6.34.0 → 6.36.0
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.
- package/README.md +11 -1
- package/dist/components/Calendar/CalendarHeader.svelte +32 -26
- package/dist/components/Calendar/CalendarMiniMonth.svelte +10 -10
- package/dist/components/Calendar/calendar.variants.js +19 -5
- package/dist/components/Chat/Chat/Chat.svelte +53 -0
- package/dist/components/Chat/Chat/Chat.svelte.d.ts +4 -0
- package/dist/components/Chat/Chat/chat.variants.d.ts +20 -0
- package/dist/components/Chat/Chat/chat.variants.js +15 -0
- package/dist/components/Chat/Chat/index.d.ts +47 -0
- package/dist/components/Chat/Chat/index.js +2 -0
- package/dist/components/Chat/ChatMessage/ChatMessage.svelte +342 -0
- package/dist/components/Chat/ChatMessage/ChatMessage.svelte.d.ts +4 -0
- package/dist/components/Chat/ChatMessage/chat-message.variants.d.ts +138 -0
- package/dist/components/Chat/ChatMessage/chat-message.variants.js +118 -0
- package/dist/components/Chat/ChatMessage/index.d.ts +102 -0
- package/dist/components/Chat/ChatMessage/index.js +2 -0
- package/dist/components/Chat/ChatMessageList/ChatMessageList.svelte +301 -0
- package/dist/components/Chat/ChatMessageList/ChatMessageList.svelte.d.ts +6 -0
- package/dist/components/Chat/ChatMessageList/chat-message-list.variants.d.ts +23 -0
- package/dist/components/Chat/ChatMessageList/chat-message-list.variants.js +32 -0
- package/dist/components/Chat/ChatMessageList/chat-scroll.d.ts +45 -0
- package/dist/components/Chat/ChatMessageList/chat-scroll.js +52 -0
- package/dist/components/Chat/ChatMessageList/index.d.ts +93 -0
- package/dist/components/Chat/ChatMessageList/index.js +3 -0
- package/dist/components/Chat/CitationChip/CitationChip.svelte +107 -0
- package/dist/components/Chat/CitationChip/CitationChip.svelte.d.ts +4 -0
- package/dist/components/Chat/CitationChip/citation-chip.variants.d.ts +40 -0
- package/dist/components/Chat/CitationChip/citation-chip.variants.js +42 -0
- package/dist/components/Chat/CitationChip/index.d.ts +69 -0
- package/dist/components/Chat/CitationChip/index.js +2 -0
- package/dist/components/Chat/CodeBlock/CodeBlock.svelte +120 -0
- package/dist/components/Chat/CodeBlock/CodeBlock.svelte.d.ts +4 -0
- package/dist/components/Chat/CodeBlock/code-block.variants.d.ts +40 -0
- package/dist/components/Chat/CodeBlock/code-block.variants.js +51 -0
- package/dist/components/Chat/CodeBlock/index.d.ts +55 -0
- package/dist/components/Chat/CodeBlock/index.js +2 -0
- package/dist/components/Chat/PromptInput/PromptInput.svelte +432 -0
- package/dist/components/Chat/PromptInput/PromptInput.svelte.d.ts +4 -0
- package/dist/components/Chat/PromptInput/index.d.ts +165 -0
- package/dist/components/Chat/PromptInput/index.js +2 -0
- package/dist/components/Chat/PromptInput/prompt-input.variants.d.ts +90 -0
- package/dist/components/Chat/PromptInput/prompt-input.variants.js +102 -0
- package/dist/components/Chat/ReasoningDisclosure/ReasoningDisclosure.svelte +107 -0
- package/dist/components/Chat/ReasoningDisclosure/ReasoningDisclosure.svelte.d.ts +4 -0
- package/dist/components/Chat/ReasoningDisclosure/index.d.ts +85 -0
- package/dist/components/Chat/ReasoningDisclosure/index.js +2 -0
- package/dist/components/Chat/ReasoningDisclosure/reasoning-disclosure.variants.d.ts +20 -0
- package/dist/components/Chat/ReasoningDisclosure/reasoning-disclosure.variants.js +31 -0
- package/dist/components/Chat/StreamingMarkdown/MdBlock.svelte +99 -0
- package/dist/components/Chat/StreamingMarkdown/MdBlock.svelte.d.ts +9 -0
- package/dist/components/Chat/StreamingMarkdown/MdInline.svelte +82 -0
- package/dist/components/Chat/StreamingMarkdown/MdInline.svelte.d.ts +9 -0
- package/dist/components/Chat/StreamingMarkdown/StreamingMarkdown.svelte +118 -0
- package/dist/components/Chat/StreamingMarkdown/StreamingMarkdown.svelte.d.ts +4 -0
- package/dist/components/Chat/StreamingMarkdown/index.d.ts +86 -0
- package/dist/components/Chat/StreamingMarkdown/index.js +2 -0
- package/dist/components/Chat/StreamingMarkdown/md-context.d.ts +58 -0
- package/dist/components/Chat/StreamingMarkdown/md-context.js +1 -0
- package/dist/components/Chat/StreamingMarkdown/streaming-markdown.variants.d.ts +145 -0
- package/dist/components/Chat/StreamingMarkdown/streaming-markdown.variants.js +73 -0
- package/dist/components/Chat/ToolCallCard/ToolCallCard.svelte +174 -0
- package/dist/components/Chat/ToolCallCard/ToolCallCard.svelte.d.ts +4 -0
- package/dist/components/Chat/ToolCallCard/index.d.ts +84 -0
- package/dist/components/Chat/ToolCallCard/index.js +2 -0
- package/dist/components/Chat/ToolCallCard/tool-call-card.variants.d.ts +38 -0
- package/dist/components/Chat/ToolCallCard/tool-call-card.variants.js +38 -0
- package/dist/components/Chat/chat.types.d.ts +82 -0
- package/dist/components/Chat/chat.types.js +7 -0
- package/dist/components/Chat/index.d.ts +22 -0
- package/dist/components/Chat/index.js +14 -0
- package/dist/components/Chat/markdown/blocks.d.ts +54 -0
- package/dist/components/Chat/markdown/blocks.js +462 -0
- package/dist/components/Chat/markdown/inline.d.ts +31 -0
- package/dist/components/Chat/markdown/inline.js +446 -0
- package/dist/components/Chat/markdown/repair.d.ts +23 -0
- package/dist/components/Chat/markdown/repair.js +337 -0
- package/dist/components/Chat/markdown/streaming-harness.d.ts +23 -0
- package/dist/components/Chat/markdown/streaming-harness.js +62 -0
- package/dist/components/Chat/markdown/types.d.ts +139 -0
- package/dist/components/Chat/markdown/types.js +13 -0
- package/dist/components/Chat/markdown/url-policy.d.ts +33 -0
- package/dist/components/Chat/markdown/url-policy.js +109 -0
- package/dist/components/FileUpload/FileUpload.svelte +52 -125
- package/dist/components/FileUpload/FileUpload.svelte.d.ts +1 -1
- package/dist/components/FileUpload/index.d.ts +2 -28
- package/dist/components/Planner/PlannerHeader.svelte +16 -13
- package/dist/components/Planner/planner.variants.js +14 -3
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/i18n/index.d.ts +268 -382
- package/dist/i18n/index.js +83 -9
- package/dist/internal/core/CoreIconButton.svelte +66 -0
- package/dist/internal/core/CoreIconButton.svelte.d.ts +13 -0
- package/dist/internal/core/CoreSpinner.svelte +49 -0
- package/dist/internal/core/CoreSpinner.svelte.d.ts +8 -0
- package/dist/internal/core/spinner-geometry.d.ts +6 -0
- package/dist/internal/core/spinner-geometry.js +6 -0
- package/dist/mint/registry.d.ts +20 -2
- package/dist/mint/registry.js +20 -2
- package/dist/primitives/Badge/Badge.svelte +5 -5
- package/dist/primitives/Badge/badge.variants.js +17 -1
- package/dist/primitives/Button/Button.svelte +4 -2
- package/dist/primitives/Collapsible/Collapsible.svelte +5 -0
- package/dist/primitives/Dialog/Dialog.svelte +7 -5
- package/dist/primitives/Dialog/dialog.variants.d.ts +7 -0
- package/dist/primitives/Dialog/dialog.variants.js +17 -0
- package/dist/primitives/Drawer/Drawer.svelte +7 -5
- package/dist/primitives/Drawer/drawer.variants.d.ts +8 -0
- package/dist/primitives/Drawer/drawer.variants.js +17 -0
- package/dist/primitives/Spinner/Spinner.svelte +3 -1
- package/dist/primitives/SplitPane/SplitPane.svelte +293 -0
- package/dist/primitives/SplitPane/SplitPane.svelte.d.ts +4 -0
- package/dist/primitives/SplitPane/index.d.ts +88 -0
- package/dist/primitives/SplitPane/index.js +2 -0
- package/dist/primitives/SplitPane/split-pane.utils.d.ts +73 -0
- package/dist/primitives/SplitPane/split-pane.utils.js +94 -0
- package/dist/primitives/SplitPane/split-pane.variants.d.ts +40 -0
- package/dist/primitives/SplitPane/split-pane.variants.js +56 -0
- package/dist/primitives/Toast/Toaster.svelte +17 -2
- package/dist/primitives/index.d.ts +2 -0
- package/dist/primitives/index.js +1 -0
- package/dist/utils/file-intake.d.ts +78 -0
- package/dist/utils/file-intake.js +144 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/variants.js +44 -10
- package/package.json +4 -6
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
import type { CodeBlockSlots, CodeBlockVariants } from './code-block.variants.js';
|
|
4
|
+
/**
|
|
5
|
+
* @description Read-only code display card with a one-click copy interaction: the copy
|
|
6
|
+
* button swaps its icon and label to a confirmation for two seconds and fires `onCopy`.
|
|
7
|
+
* Renders raw text only — no built-in syntax highlighting; a consumer or the
|
|
8
|
+
* StreamingMarkdown renderer can layer highlighting in via a snippet. Used by
|
|
9
|
+
* StreamingMarkdown for fenced code blocks, and standalone for any code snippet.
|
|
10
|
+
*
|
|
11
|
+
* @tag ai
|
|
12
|
+
* @tag display
|
|
13
|
+
* @related StreamingMarkdown
|
|
14
|
+
* @stability experimental
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```svelte
|
|
18
|
+
* <CodeBlock lang="ts" code={`const x = 1;`} onCopy={(c) => track(c)} />
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export interface CodeBlockProps extends CodeBlockVariants, Omit<HTMLAttributes<HTMLElement>, 'children' | 'class'> {
|
|
22
|
+
/** The code to display and copy. Rendered as raw text (no highlighting). */
|
|
23
|
+
code: string;
|
|
24
|
+
/** Language label shown in the header. Display-only — does not drive highlighting. */
|
|
25
|
+
lang?: string;
|
|
26
|
+
/** Show the copy button in the header. */
|
|
27
|
+
showCopy?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Soft-wrap long lines. `false` (default) scrolls horizontally inside the
|
|
30
|
+
* block; `true` wraps with `whitespace-pre-wrap` + word breaking.
|
|
31
|
+
*/
|
|
32
|
+
wrap?: boolean;
|
|
33
|
+
/** Accessible label / tooltip for the copy button in its idle state. */
|
|
34
|
+
copyLabel?: string;
|
|
35
|
+
/** Accessible label shown for two seconds after a successful copy. */
|
|
36
|
+
copiedLabel?: string;
|
|
37
|
+
/** Called with the copied code after a successful clipboard write. */
|
|
38
|
+
onCopy?: (code: string) => void;
|
|
39
|
+
/** Extra header actions rendered to the right, before the copy button. */
|
|
40
|
+
actions?: Snippet;
|
|
41
|
+
/** Extra classes merged onto the root element. */
|
|
42
|
+
class?: string;
|
|
43
|
+
/** Remove all default tv classes. */
|
|
44
|
+
unstyled?: boolean;
|
|
45
|
+
/** Per-slot class overrides. Slots: root | header | langLabel | copyButton | pre | code */
|
|
46
|
+
slotClasses?: Partial<Record<CodeBlockSlots, string>>;
|
|
47
|
+
/**
|
|
48
|
+
* Apply a named preset registered via `<BlocksProvider presets={{ CodeBlock: {...} }}>`.
|
|
49
|
+
* Prefer this over `class` overrides when the requested look falls outside the
|
|
50
|
+
* semantic intent palette.
|
|
51
|
+
*/
|
|
52
|
+
preset?: string;
|
|
53
|
+
}
|
|
54
|
+
export { default as CodeBlock } from './CodeBlock.svelte';
|
|
55
|
+
export { type CodeBlockVariants, codeBlockVariants } from './code-block.variants.js';
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onDestroy, tick } from 'svelte';
|
|
3
|
+
import { useBlocksI18n } from '../../..';
|
|
4
|
+
import { getBlocksConfig, resolveSlotClasses } from '../../../provider';
|
|
5
|
+
import { resolveIcon } from '../../../icons';
|
|
6
|
+
import SendIconDefault from '../../../icons/SendIcon.svelte';
|
|
7
|
+
import SquareIconDefault from '../../../icons/SquareIcon.svelte';
|
|
8
|
+
import PaperclipIconDefault from '../../../icons/PaperclipIcon.svelte';
|
|
9
|
+
import CloseIconDefault from '../../../icons/CloseIcon.svelte';
|
|
10
|
+
import FileIconDefault from '../../../icons/FileIcon.svelte';
|
|
11
|
+
import {
|
|
12
|
+
partitionIntake,
|
|
13
|
+
revokeIntakePreviews,
|
|
14
|
+
formatFileSize,
|
|
15
|
+
type FileIntakeEntry,
|
|
16
|
+
type FileIntakeConstraints,
|
|
17
|
+
type FileIntakeMessages
|
|
18
|
+
} from '../../../utils/file-intake';
|
|
19
|
+
import { promptInputVariants, type PromptInputVariants } from './prompt-input.variants';
|
|
20
|
+
import type { PromptInputProps } from './index';
|
|
21
|
+
|
|
22
|
+
let {
|
|
23
|
+
value = $bindable(''),
|
|
24
|
+
attachments = $bindable<FileIntakeEntry[]>([]),
|
|
25
|
+
onValueChange,
|
|
26
|
+
onSubmit,
|
|
27
|
+
onStop,
|
|
28
|
+
onAttachmentReject,
|
|
29
|
+
submitOn = 'enter',
|
|
30
|
+
clearOnSubmit = true,
|
|
31
|
+
placeholder,
|
|
32
|
+
disabled = false,
|
|
33
|
+
busy = false,
|
|
34
|
+
autofocus = false,
|
|
35
|
+
minRows = 1,
|
|
36
|
+
maxRows = 8,
|
|
37
|
+
label = 'Message',
|
|
38
|
+
sendLabel = 'Send',
|
|
39
|
+
stopLabel = 'Stop',
|
|
40
|
+
attachLabel = 'Attach file',
|
|
41
|
+
removeAttachmentLabel = (name: string) => `Remove ${name}`,
|
|
42
|
+
allowAttachments = false,
|
|
43
|
+
preventDocumentDrop = true,
|
|
44
|
+
accept,
|
|
45
|
+
maxFiles,
|
|
46
|
+
maxFileSize,
|
|
47
|
+
validate,
|
|
48
|
+
size = 'md',
|
|
49
|
+
leading,
|
|
50
|
+
trailing,
|
|
51
|
+
hint,
|
|
52
|
+
class: className,
|
|
53
|
+
unstyled: unstyledProp = false,
|
|
54
|
+
slotClasses: slotClassesProp = {},
|
|
55
|
+
preset,
|
|
56
|
+
...restProps
|
|
57
|
+
}: PromptInputProps = $props();
|
|
58
|
+
|
|
59
|
+
const bt = useBlocksI18n();
|
|
60
|
+
|
|
61
|
+
const blocksConfig = getBlocksConfig();
|
|
62
|
+
const unstyled = $derived(unstyledProp || blocksConfig?.unstyled || false);
|
|
63
|
+
|
|
64
|
+
const SendIcon = resolveIcon('send', SendIconDefault);
|
|
65
|
+
const SquareIcon = resolveIcon('square', SquareIconDefault);
|
|
66
|
+
const PaperclipIcon = resolveIcon('paperclip', PaperclipIconDefault);
|
|
67
|
+
const CloseIcon = resolveIcon('close', CloseIconDefault);
|
|
68
|
+
const FileIcon = resolveIcon('file', FileIconDefault);
|
|
69
|
+
|
|
70
|
+
const variantProps: PromptInputVariants = $derived({ size });
|
|
71
|
+
const styles = $derived(promptInputVariants(variantProps));
|
|
72
|
+
const slotClasses = $derived(
|
|
73
|
+
resolveSlotClasses(blocksConfig, 'PromptInput', preset, variantProps, slotClassesProp)
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
function cls(name: keyof typeof slotClasses, extra?: string | (string | undefined)[]) {
|
|
77
|
+
const extras = Array.isArray(extra) ? extra : [extra];
|
|
78
|
+
if (unstyled) {
|
|
79
|
+
return [slotClasses?.[name], ...extras].filter(Boolean).join(' ');
|
|
80
|
+
}
|
|
81
|
+
const slotFns = styles as Record<string, (args: { class?: unknown }) => string>;
|
|
82
|
+
return slotFns[name]({ class: [slotClasses?.[name], ...extras] });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── IDs & a11y ───────────────────────────────────────────────────────────────
|
|
86
|
+
const propsId = $props.id();
|
|
87
|
+
const fieldId = `prompt-input-${propsId}`;
|
|
88
|
+
const errorId = `${fieldId}-error`;
|
|
89
|
+
|
|
90
|
+
// aria-keyshortcuts reflects the active submit binding so AT users know the
|
|
91
|
+
// gesture (Enter vs Cmd/Ctrl+Enter) that sends the message.
|
|
92
|
+
const keyshortcuts = $derived(submitOn === 'enter' ? 'Enter' : 'Meta+Enter Control+Enter');
|
|
93
|
+
|
|
94
|
+
// ── Auto-resize (measured pattern, mirrors Textarea.svelte) ────────────────────
|
|
95
|
+
let textareaRef = $state<HTMLTextAreaElement>();
|
|
96
|
+
const lineHeight = $derived(size === 'sm' ? 20 : 24);
|
|
97
|
+
|
|
98
|
+
function adjustHeight() {
|
|
99
|
+
if (!textareaRef) return;
|
|
100
|
+
textareaRef.style.height = 'auto';
|
|
101
|
+
const minHeight = minRows * lineHeight;
|
|
102
|
+
const maxHeight = maxRows * lineHeight;
|
|
103
|
+
const scrollHeight = textareaRef.scrollHeight;
|
|
104
|
+
textareaRef.style.height = `${Math.min(Math.max(scrollHeight, minHeight), maxHeight)}px`;
|
|
105
|
+
textareaRef.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Re-measure on every value change (typing and programmatic clear alike).
|
|
109
|
+
$effect(() => {
|
|
110
|
+
void value;
|
|
111
|
+
adjustHeight();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Focus once when requested — a bare native `autofocus` attribute is
|
|
115
|
+
// unreliable for a chromeless textarea mounted inside a bordered surface.
|
|
116
|
+
$effect(() => {
|
|
117
|
+
if (autofocus && textareaRef) textareaRef.focus();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ── Value ──────────────────────────────────────────────────────────────────
|
|
121
|
+
function handleInput(event: Event & { currentTarget: HTMLTextAreaElement }) {
|
|
122
|
+
value = event.currentTarget.value;
|
|
123
|
+
onValueChange?.(value);
|
|
124
|
+
adjustHeight();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const canSubmit = $derived((value.trim().length > 0 || attachments.length > 0) && !disabled);
|
|
128
|
+
|
|
129
|
+
function submit() {
|
|
130
|
+
if (disabled || busy) return;
|
|
131
|
+
const text = value.trim();
|
|
132
|
+
if (!text && attachments.length === 0) return;
|
|
133
|
+
// Hand out a shallow copy so a consumer that keeps the array by reference
|
|
134
|
+
// (e.g. clearOnSubmit=false) is not aliased to our still-mutating state.
|
|
135
|
+
onSubmit({ text, attachments: [...attachments] });
|
|
136
|
+
// Clear any stale inline rejection on EVERY successful submit, regardless
|
|
137
|
+
// of clearOnSubmit — the error described the previous intake, not this send.
|
|
138
|
+
error = '';
|
|
139
|
+
if (clearOnSubmit) {
|
|
140
|
+
value = '';
|
|
141
|
+
onValueChange?.('');
|
|
142
|
+
// Ownership of the accepted entries transfers to the consumer — the
|
|
143
|
+
// submitted files (and their preview object-URLs) live on in the
|
|
144
|
+
// consumer's message list, so we clear the reference WITHOUT revoking.
|
|
145
|
+
attachments = [];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function handleKeydown(event: KeyboardEvent) {
|
|
150
|
+
if (event.key !== 'Enter') return;
|
|
151
|
+
// IME guard — never submit while a composition is in progress.
|
|
152
|
+
if (event.isComposing || event.keyCode === 229) return;
|
|
153
|
+
|
|
154
|
+
const withMod = event.metaKey || event.ctrlKey;
|
|
155
|
+
if (submitOn === 'enter') {
|
|
156
|
+
if (event.shiftKey) return; // Shift+Enter inserts a newline.
|
|
157
|
+
// While busy, DON'T preventDefault — let Enter insert a newline so the
|
|
158
|
+
// user can keep composing during a streaming response (mod-enter parity).
|
|
159
|
+
// Swallowing the keystroke here would drop it entirely.
|
|
160
|
+
if (busy) return;
|
|
161
|
+
// Empty-idle Enter still preventDefaults: submit() no-ops, and we
|
|
162
|
+
// deliberately suppress the leading newline (chat convention).
|
|
163
|
+
event.preventDefault();
|
|
164
|
+
submit();
|
|
165
|
+
} else {
|
|
166
|
+
if (!withMod) return; // Plain Enter inserts a newline in mod-enter mode.
|
|
167
|
+
event.preventDefault();
|
|
168
|
+
submit();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Attachments ──────────────────────────────────────────────────────────────
|
|
173
|
+
let error = $state('');
|
|
174
|
+
let dragging = $state(false);
|
|
175
|
+
let fileInputEl = $state<HTMLInputElement>();
|
|
176
|
+
let rootEl = $state<HTMLDivElement>();
|
|
177
|
+
|
|
178
|
+
const acceptString = $derived(Array.isArray(accept) ? accept.join(',') : (accept ?? ''));
|
|
179
|
+
|
|
180
|
+
// Error texts reuse FileUpload's i18n keys exactly — no new keys for a second
|
|
181
|
+
// intake surface. `bt` is reactive, so building this per-intake keeps locale
|
|
182
|
+
// changes correct.
|
|
183
|
+
const messages: FileIntakeMessages = {
|
|
184
|
+
invalidType: (type) => bt('fileUpload.invalidType', { type }),
|
|
185
|
+
tooLarge: (formattedSize) => bt('fileUpload.tooLarge', { size: formattedSize }),
|
|
186
|
+
tooSmall: (formattedSize) => bt('fileUpload.tooSmall', { size: formattedSize }),
|
|
187
|
+
exists: () => bt('fileUpload.exists'),
|
|
188
|
+
tooMany: (count) => bt('fileUpload.tooMany', { count: String(count) })
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
function addFiles(incoming: File[]) {
|
|
192
|
+
if (!allowAttachments || disabled || incoming.length === 0) return;
|
|
193
|
+
const constraints: FileIntakeConstraints = { accept, maxFiles, maxFileSize, validate };
|
|
194
|
+
const { accepted, rejected } = partitionIntake(
|
|
195
|
+
incoming,
|
|
196
|
+
attachments,
|
|
197
|
+
constraints,
|
|
198
|
+
messages,
|
|
199
|
+
'attachment'
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
if (accepted.length > 0) {
|
|
203
|
+
attachments = [...attachments, ...accepted];
|
|
204
|
+
error = ''; // A successful add clears the previous rejection line.
|
|
205
|
+
}
|
|
206
|
+
if (rejected.length > 0) {
|
|
207
|
+
error = rejected[0].errors[0]?.message ?? '';
|
|
208
|
+
onAttachmentReject?.(rejected);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function removeAttachment(entry: FileIntakeEntry, index: number) {
|
|
213
|
+
// Explicit removal — the entry never reached the consumer, so we own and
|
|
214
|
+
// revoke its preview object-URL here.
|
|
215
|
+
revokeIntakePreviews([entry]);
|
|
216
|
+
attachments = attachments.filter((a) => a.id !== entry.id);
|
|
217
|
+
// Move focus deterministically after the chip leaves the DOM so keyboard
|
|
218
|
+
// users don't get dropped to <body>: the chip now at this index (the one
|
|
219
|
+
// that was next), else the last remaining chip (the previous one), else the
|
|
220
|
+
// textarea when the strip is empty.
|
|
221
|
+
await tick();
|
|
222
|
+
const buttons = rootEl?.querySelectorAll<HTMLButtonElement>('[data-attachment-remove]');
|
|
223
|
+
if (buttons && buttons.length > 0) {
|
|
224
|
+
buttons[Math.min(index, buttons.length - 1)]?.focus();
|
|
225
|
+
} else {
|
|
226
|
+
textareaRef?.focus();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function openFilePicker() {
|
|
231
|
+
if (!disabled) fileInputEl?.click();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function handleFileInputChange(event: Event & { currentTarget: HTMLInputElement }) {
|
|
235
|
+
const selected = Array.from(event.currentTarget.files ?? []);
|
|
236
|
+
if (selected.length > 0) addFiles(selected);
|
|
237
|
+
event.currentTarget.value = '';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function handlePaste(event: ClipboardEvent) {
|
|
241
|
+
if (!allowAttachments || disabled) return;
|
|
242
|
+
const pasted = Array.from(event.clipboardData?.files ?? []);
|
|
243
|
+
if (pasted.length > 0) {
|
|
244
|
+
event.preventDefault();
|
|
245
|
+
addFiles(pasted);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function handleDragOver(event: DragEvent) {
|
|
250
|
+
if (!allowAttachments || disabled) return;
|
|
251
|
+
event.preventDefault();
|
|
252
|
+
dragging = true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function handleDragLeave(event: DragEvent) {
|
|
256
|
+
// Only clear when leaving the composer itself, not when moving between
|
|
257
|
+
// its children (which fire dragleave against the child).
|
|
258
|
+
if (event.currentTarget === event.target) dragging = false;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function handleDrop(event: DragEvent) {
|
|
262
|
+
if (!allowAttachments || disabled) return;
|
|
263
|
+
dragging = false;
|
|
264
|
+
const dropped = Array.from(event.dataTransfer?.files ?? []);
|
|
265
|
+
// Only claim the drop when it carries files. A text-selection drop must
|
|
266
|
+
// fall through to the native textarea (inserts the dragged text), so we
|
|
267
|
+
// never preventDefault for it.
|
|
268
|
+
if (dropped.length === 0) return;
|
|
269
|
+
event.preventDefault();
|
|
270
|
+
addFiles(dropped);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Prevent a file misdropped OUTSIDE the composer from navigating the page
|
|
274
|
+
// away (browser default for a dropped file). Effective only while the
|
|
275
|
+
// attachment surface is enabled. Mirrors FileUpload's document guard.
|
|
276
|
+
$effect(() => {
|
|
277
|
+
if (!allowAttachments || !preventDocumentDrop) return;
|
|
278
|
+
function preventDrop(event: DragEvent) {
|
|
279
|
+
// Only guard drops OUTSIDE the composer. Inside, the composer's own
|
|
280
|
+
// handlers act (file drops), and a text-selection drop must reach the
|
|
281
|
+
// native textarea — so we must not preventDefault the bubbled event here.
|
|
282
|
+
if (rootEl && event.target instanceof Node && rootEl.contains(event.target)) return;
|
|
283
|
+
event.preventDefault();
|
|
284
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = 'none';
|
|
285
|
+
}
|
|
286
|
+
document.addEventListener('dragover', preventDrop);
|
|
287
|
+
document.addEventListener('drop', preventDrop);
|
|
288
|
+
return () => {
|
|
289
|
+
document.removeEventListener('dragover', preventDrop);
|
|
290
|
+
document.removeEventListener('drop', preventDrop);
|
|
291
|
+
};
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Reset the drag highlight if a drag ends anywhere (dropped outside, or the
|
|
295
|
+
// pointer left the window) — otherwise the ring stays stuck after such a drag.
|
|
296
|
+
$effect(() => {
|
|
297
|
+
if (!allowAttachments) return;
|
|
298
|
+
function clearDrag() {
|
|
299
|
+
dragging = false;
|
|
300
|
+
}
|
|
301
|
+
window.addEventListener('dragend', clearDrag);
|
|
302
|
+
return () => window.removeEventListener('dragend', clearDrag);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Non-submitted attachments own their previews — revoke on teardown. Submitted
|
|
306
|
+
// entries were already cleared from `attachments` (without revoking), so this
|
|
307
|
+
// only frees what the user left behind.
|
|
308
|
+
onDestroy(() => {
|
|
309
|
+
revokeIntakePreviews(attachments);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const iconSize = $derived(size === 'sm' ? 16 : 18);
|
|
313
|
+
</script>
|
|
314
|
+
|
|
315
|
+
<div
|
|
316
|
+
bind:this={rootEl}
|
|
317
|
+
{...restProps}
|
|
318
|
+
class={cls('root', className)}
|
|
319
|
+
data-dragging={allowAttachments && dragging ? 'true' : undefined}
|
|
320
|
+
ondragover={allowAttachments ? handleDragOver : undefined}
|
|
321
|
+
ondragleave={allowAttachments ? handleDragLeave : undefined}
|
|
322
|
+
ondrop={allowAttachments ? handleDrop : undefined}
|
|
323
|
+
>
|
|
324
|
+
{#if allowAttachments}
|
|
325
|
+
<input
|
|
326
|
+
bind:this={fileInputEl}
|
|
327
|
+
type="file"
|
|
328
|
+
accept={acceptString || undefined}
|
|
329
|
+
multiple={maxFiles !== 1}
|
|
330
|
+
{disabled}
|
|
331
|
+
class="sr-only"
|
|
332
|
+
tabindex={-1}
|
|
333
|
+
aria-hidden="true"
|
|
334
|
+
onchange={handleFileInputChange}
|
|
335
|
+
/>
|
|
336
|
+
{/if}
|
|
337
|
+
|
|
338
|
+
{#if allowAttachments && attachments.length > 0}
|
|
339
|
+
<ul class={cls('attachmentsStrip')} aria-label={attachLabel}>
|
|
340
|
+
{#each attachments as entry, i (entry.id)}
|
|
341
|
+
<li class={cls('attachmentChip')}>
|
|
342
|
+
<!-- Decorative: the adjacent attachmentName span carries the file
|
|
343
|
+
name — a filled alt would read it twice (review finding). -->
|
|
344
|
+
<span class={cls('attachmentThumb')} aria-hidden="true">
|
|
345
|
+
{#if entry.preview}
|
|
346
|
+
<img src={entry.preview} alt="" />
|
|
347
|
+
{:else}
|
|
348
|
+
<FileIcon size={16} />
|
|
349
|
+
{/if}
|
|
350
|
+
</span>
|
|
351
|
+
<span class={cls('attachmentName')} title={entry.file.name}>{entry.file.name}</span>
|
|
352
|
+
<span class={cls('attachmentSize')}>{formatFileSize(entry.file.size)}</span>
|
|
353
|
+
<button
|
|
354
|
+
type="button"
|
|
355
|
+
class={cls('attachmentRemove')}
|
|
356
|
+
data-attachment-remove
|
|
357
|
+
aria-label={removeAttachmentLabel(entry.file.name)}
|
|
358
|
+
onclick={() => removeAttachment(entry, i)}
|
|
359
|
+
>
|
|
360
|
+
<CloseIcon size={14} />
|
|
361
|
+
</button>
|
|
362
|
+
</li>
|
|
363
|
+
{/each}
|
|
364
|
+
</ul>
|
|
365
|
+
{/if}
|
|
366
|
+
|
|
367
|
+
<textarea
|
|
368
|
+
bind:this={textareaRef}
|
|
369
|
+
id={fieldId}
|
|
370
|
+
class={cls('textarea')}
|
|
371
|
+
{value}
|
|
372
|
+
{placeholder}
|
|
373
|
+
{disabled}
|
|
374
|
+
rows={minRows}
|
|
375
|
+
aria-label={label}
|
|
376
|
+
aria-keyshortcuts={keyshortcuts}
|
|
377
|
+
aria-describedby={error ? errorId : undefined}
|
|
378
|
+
oninput={handleInput}
|
|
379
|
+
onkeydown={handleKeydown}
|
|
380
|
+
onpaste={handlePaste}></textarea>
|
|
381
|
+
|
|
382
|
+
<div class={cls('actions')}>
|
|
383
|
+
<div class={cls('leading')}>
|
|
384
|
+
{#if allowAttachments}
|
|
385
|
+
<button
|
|
386
|
+
type="button"
|
|
387
|
+
class={cls('attachButton')}
|
|
388
|
+
aria-label={attachLabel}
|
|
389
|
+
{disabled}
|
|
390
|
+
onclick={openFilePicker}
|
|
391
|
+
>
|
|
392
|
+
<PaperclipIcon size={iconSize} />
|
|
393
|
+
</button>
|
|
394
|
+
{/if}
|
|
395
|
+
{@render leading?.()}
|
|
396
|
+
</div>
|
|
397
|
+
|
|
398
|
+
<div class={cls('trailing')}>
|
|
399
|
+
{@render trailing?.()}
|
|
400
|
+
{#if busy}
|
|
401
|
+
<button
|
|
402
|
+
type="button"
|
|
403
|
+
class={cls('stopButton')}
|
|
404
|
+
aria-label={stopLabel}
|
|
405
|
+
onclick={() => onStop?.()}
|
|
406
|
+
>
|
|
407
|
+
<SquareIcon size={iconSize} />
|
|
408
|
+
</button>
|
|
409
|
+
{:else}
|
|
410
|
+
<button
|
|
411
|
+
type="button"
|
|
412
|
+
class={cls('sendButton')}
|
|
413
|
+
aria-label={sendLabel}
|
|
414
|
+
disabled={!canSubmit}
|
|
415
|
+
onclick={submit}
|
|
416
|
+
>
|
|
417
|
+
<SendIcon size={iconSize} />
|
|
418
|
+
</button>
|
|
419
|
+
{/if}
|
|
420
|
+
</div>
|
|
421
|
+
</div>
|
|
422
|
+
|
|
423
|
+
<!-- Live rejection line — the region is PERMANENTLY in the DOM (present
|
|
424
|
+
before its text, so AT reliably announces the change; see ChatMessage's
|
|
425
|
+
copy-status span). Empty state collapses to sr-only: still in the
|
|
426
|
+
accessibility tree, but reserving no layout. -->
|
|
427
|
+
<div id={errorId} class={error ? cls('error') : 'sr-only'} role="status">{error}</div>
|
|
428
|
+
|
|
429
|
+
{#if hint}
|
|
430
|
+
<div class={cls('hint')}>{@render hint()}</div>
|
|
431
|
+
{/if}
|
|
432
|
+
</div>
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
import type { FileIntakeEntry, FileIntakeRejection } from '../../../utils/file-intake.js';
|
|
4
|
+
import type { PromptInputSlots, PromptInputVariants } from './prompt-input.variants.js';
|
|
5
|
+
/**
|
|
6
|
+
* @description The chat composer: an auto-growing textarea in a single bordered
|
|
7
|
+
* surface with a send button that flips to a stop button while a response is
|
|
8
|
+
* streaming. `onSubmit({ text, attachments })` fires with trimmed text when there
|
|
9
|
+
* is content (or at least one attachment); `onStop` fires from the stop button.
|
|
10
|
+
* Attachment intake (paperclip picker, clipboard paste, drag-and-drop, chip strip)
|
|
11
|
+
* reuses the shared `file-intake` core — the same accept/size/count validation and
|
|
12
|
+
* preview-URL lifecycle as FileUpload — and is opt-in via `allowAttachments`.
|
|
13
|
+
*
|
|
14
|
+
* @tag ai
|
|
15
|
+
* @tag form
|
|
16
|
+
* @related Textarea
|
|
17
|
+
* @related FileUpload
|
|
18
|
+
* @related Chat
|
|
19
|
+
* @stability experimental
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```svelte
|
|
23
|
+
* <PromptInput
|
|
24
|
+
* bind:value={draft}
|
|
25
|
+
* busy={isStreaming}
|
|
26
|
+
* onSubmit={({ text }) => send(text)}
|
|
27
|
+
* onStop={abort}
|
|
28
|
+
* />
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* @example With attachments and Cmd/Ctrl+Enter to send
|
|
32
|
+
* ```svelte
|
|
33
|
+
* <PromptInput
|
|
34
|
+
* bind:value={draft}
|
|
35
|
+
* bind:attachments={files}
|
|
36
|
+
* allowAttachments
|
|
37
|
+
* accept="image/*"
|
|
38
|
+
* maxFiles={4}
|
|
39
|
+
* submitOn="mod-enter"
|
|
40
|
+
* onSubmit={({ text, attachments }) => send(text, attachments)}
|
|
41
|
+
* />
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export interface PromptInputProps extends PromptInputVariants, Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'class' | 'onsubmit'> {
|
|
45
|
+
/** The composer's text (bindable). */
|
|
46
|
+
value?: string;
|
|
47
|
+
/** Called whenever the text changes (including the clear after submit). */
|
|
48
|
+
onValueChange?: (value: string) => void;
|
|
49
|
+
/**
|
|
50
|
+
* The accepted attachments (bindable). Populated by the picker / paste / drop
|
|
51
|
+
* when `allowAttachments` is set. On submit these are handed to `onSubmit` and
|
|
52
|
+
* — with `clearOnSubmit` — cleared from here **without** revoking their preview
|
|
53
|
+
* object-URLs, because ownership transfers to the consumer's message list.
|
|
54
|
+
*
|
|
55
|
+
* Removing chips through the built-in UI revokes their preview URLs for you.
|
|
56
|
+
* Clearing or splicing this array **externally** (e.g. reassigning
|
|
57
|
+
* `bind:attachments`) bypasses that cleanup — such mutations must revoke the
|
|
58
|
+
* dropped entries' previews themselves via `revokeIntakePreviews` (exported
|
|
59
|
+
* from `$lib/utils/file-intake`) to avoid leaking object-URLs.
|
|
60
|
+
*/
|
|
61
|
+
attachments?: FileIntakeEntry[];
|
|
62
|
+
/**
|
|
63
|
+
* Fired on send with the trimmed text and the current attachments. Only fires
|
|
64
|
+
* when there is text or at least one attachment, and never while `busy` or
|
|
65
|
+
* `disabled`.
|
|
66
|
+
*/
|
|
67
|
+
onSubmit: (payload: {
|
|
68
|
+
text: string;
|
|
69
|
+
attachments: FileIntakeEntry[];
|
|
70
|
+
}) => void;
|
|
71
|
+
/** Fired by the stop button (shown in place of send while `busy`). */
|
|
72
|
+
onStop?: () => void;
|
|
73
|
+
/**
|
|
74
|
+
* Called with the rejected files when an add is refused (bad type, too large,
|
|
75
|
+
* over `maxFiles`, duplicate, or custom `validate`). The first rejection's
|
|
76
|
+
* message also surfaces inline; it clears on the next successful add.
|
|
77
|
+
*/
|
|
78
|
+
onAttachmentReject?: (rejections: FileIntakeRejection[]) => void;
|
|
79
|
+
/**
|
|
80
|
+
* Which key gesture sends the message.
|
|
81
|
+
* - `enter` (default) — Enter sends, Shift+Enter inserts a newline.
|
|
82
|
+
* - `mod-enter` — Cmd/Ctrl+Enter sends, Enter inserts a newline.
|
|
83
|
+
*
|
|
84
|
+
* Submission is always suppressed mid-IME-composition.
|
|
85
|
+
* @default 'enter'
|
|
86
|
+
*/
|
|
87
|
+
submitOn?: 'enter' | 'mod-enter';
|
|
88
|
+
/**
|
|
89
|
+
* Clear the text and attachments after a successful submit. Preview URLs of
|
|
90
|
+
* the submitted attachments are intentionally **not** revoked — see
|
|
91
|
+
* `attachments`.
|
|
92
|
+
* @default true
|
|
93
|
+
*/
|
|
94
|
+
clearOnSubmit?: boolean;
|
|
95
|
+
/** Placeholder text for the textarea. */
|
|
96
|
+
placeholder?: string;
|
|
97
|
+
/** Disables the whole composer (textarea + buttons). @default false */
|
|
98
|
+
disabled?: boolean;
|
|
99
|
+
/**
|
|
100
|
+
* A response is in flight: the send button is replaced by a stop button and
|
|
101
|
+
* Enter no longer submits.
|
|
102
|
+
* @default false
|
|
103
|
+
*/
|
|
104
|
+
busy?: boolean;
|
|
105
|
+
/** Focus the textarea on mount. @default false */
|
|
106
|
+
autofocus?: boolean;
|
|
107
|
+
/** Minimum visible rows of the auto-growing textarea. @default 1 */
|
|
108
|
+
minRows?: number;
|
|
109
|
+
/** Maximum visible rows before the textarea scrolls internally. @default 8 */
|
|
110
|
+
maxRows?: number;
|
|
111
|
+
/** Accessible name for the textarea (rendered as `aria-label`). @default 'Message' */
|
|
112
|
+
label?: string;
|
|
113
|
+
/** Accessible label for the send button. @default 'Send' */
|
|
114
|
+
sendLabel?: string;
|
|
115
|
+
/** Accessible label for the stop button. @default 'Stop' */
|
|
116
|
+
stopLabel?: string;
|
|
117
|
+
/** Accessible label for the attach button. @default 'Attach file' */
|
|
118
|
+
attachLabel?: string;
|
|
119
|
+
/** Accessible label factory for a chip's remove button. @default (name) => `Remove ${name}` */
|
|
120
|
+
removeAttachmentLabel?: (name: string) => string;
|
|
121
|
+
/**
|
|
122
|
+
* Enable the attachment surface: paperclip picker, clipboard-image paste,
|
|
123
|
+
* drag-and-drop, and the chip strip above the textarea.
|
|
124
|
+
* @default false
|
|
125
|
+
*/
|
|
126
|
+
allowAttachments?: boolean;
|
|
127
|
+
/**
|
|
128
|
+
* Prevent browser navigation when a file is dropped outside the composer.
|
|
129
|
+
* Only takes effect while `allowAttachments` is set. @default true
|
|
130
|
+
*/
|
|
131
|
+
preventDocumentDrop?: boolean;
|
|
132
|
+
/** Accepted MIME types / extensions (e.g. `'image/*'`, `['.pdf', 'image/png']`). */
|
|
133
|
+
accept?: string | string[];
|
|
134
|
+
/** Maximum number of attachments across the whole list. */
|
|
135
|
+
maxFiles?: number;
|
|
136
|
+
/** Maximum attachment size in bytes. */
|
|
137
|
+
maxFileSize?: number;
|
|
138
|
+
/** Custom per-file validation. Return an errors array or `null`. */
|
|
139
|
+
validate?: (file: File) => FileIntakeEntry['errors'] | null;
|
|
140
|
+
/** Content in the leading (left) action zone, after the attach button. */
|
|
141
|
+
leading?: Snippet;
|
|
142
|
+
/** Content in the trailing (right) action zone, before the send/stop button. */
|
|
143
|
+
trailing?: Snippet;
|
|
144
|
+
/** Helper line rendered under the composer (e.g. "Enter to send"). */
|
|
145
|
+
hint?: Snippet;
|
|
146
|
+
/** Extra classes merged onto the root element. */
|
|
147
|
+
class?: string;
|
|
148
|
+
/** Remove all default tv() classes. */
|
|
149
|
+
unstyled?: boolean;
|
|
150
|
+
/**
|
|
151
|
+
* Per-slot class overrides. Slots: root | attachmentsStrip | attachmentChip |
|
|
152
|
+
* attachmentThumb | attachmentName | attachmentSize | attachmentRemove |
|
|
153
|
+
* textarea | actions | leading | trailing | attachButton | sendButton |
|
|
154
|
+
* stopButton | error | hint
|
|
155
|
+
*/
|
|
156
|
+
slotClasses?: Partial<Record<PromptInputSlots, string>>;
|
|
157
|
+
/**
|
|
158
|
+
* Apply a named preset registered via `<BlocksProvider presets={{ PromptInput: {...} }}>`.
|
|
159
|
+
* Prefer this over `class` overrides when the requested look falls outside the
|
|
160
|
+
* semantic intent palette.
|
|
161
|
+
*/
|
|
162
|
+
preset?: string;
|
|
163
|
+
}
|
|
164
|
+
export { default as PromptInput } from './PromptInput.svelte';
|
|
165
|
+
export { type PromptInputVariants, promptInputVariants } from './prompt-input.variants.js';
|