@juspay/svelte-ui-components 2.129.1 → 2.130.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/dist/Chat/Chat.svelte +2 -0
- package/dist/Chat/properties.d.ts +2 -0
- package/dist/ChatMessage/ChatMessage.svelte +6 -2
- package/dist/ChatMessage/properties.d.ts +7 -0
- package/dist/ChatMessageList/ChatMessageList.svelte +183 -40
- package/dist/ChatMessageList/ChatMessageList.svelte.d.ts +3 -1
- package/dist/ChatMessageList/properties.d.ts +32 -0
- package/package.json +1 -1
package/dist/Chat/Chat.svelte
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
headerActions,
|
|
31
31
|
headerContent,
|
|
32
32
|
message,
|
|
33
|
+
messageBody,
|
|
33
34
|
messageAttachments,
|
|
34
35
|
empty,
|
|
35
36
|
composerLeading,
|
|
@@ -92,6 +93,7 @@
|
|
|
92
93
|
{messages}
|
|
93
94
|
{autoscroll}
|
|
94
95
|
{message}
|
|
96
|
+
{messageBody}
|
|
95
97
|
{messageAttachments}
|
|
96
98
|
{empty}
|
|
97
99
|
{allowCopy}
|
|
@@ -29,6 +29,8 @@ export type OptionalChatProperties = {
|
|
|
29
29
|
headerActions?: Snippet;
|
|
30
30
|
headerContent?: Snippet;
|
|
31
31
|
message?: Snippet<[ChatMessageData]>;
|
|
32
|
+
/** Per-message bubble body, threaded through to ChatMessageList's `messageBody`. */
|
|
33
|
+
messageBody?: Snippet<[ChatMessageData]>;
|
|
32
34
|
messageAttachments?: Snippet<[ChatMessageData]>;
|
|
33
35
|
empty?: Snippet;
|
|
34
36
|
composerLeading?: Snippet;
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
role,
|
|
14
14
|
content = '',
|
|
15
15
|
html,
|
|
16
|
+
body,
|
|
16
17
|
streaming = false,
|
|
17
18
|
status,
|
|
18
19
|
avatar,
|
|
@@ -37,7 +38,8 @@
|
|
|
37
38
|
let copyResetTimer: ReturnType<typeof setTimeout> | null = null;
|
|
38
39
|
|
|
39
40
|
let hasHtml = $derived(typeof html === 'string' && html.length > 0);
|
|
40
|
-
let
|
|
41
|
+
let hasBody = $derived(typeof body === 'function');
|
|
42
|
+
let hasContent = $derived(hasBody || hasHtml || content.length > 0);
|
|
41
43
|
let showTyping = $derived(streaming && !hasContent);
|
|
42
44
|
let showRetry = $derived(typeof onretry === 'function');
|
|
43
45
|
let showFeedback = $derived(typeof onfeedback === 'function');
|
|
@@ -91,7 +93,9 @@
|
|
|
91
93
|
{/if}
|
|
92
94
|
|
|
93
95
|
<div class="bubble">
|
|
94
|
-
{#if
|
|
96
|
+
{#if hasBody}
|
|
97
|
+
<div class="body">{@render body?.()}</div>
|
|
98
|
+
{:else if hasHtml}
|
|
95
99
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
96
100
|
<div class="body">{@html html}</div>
|
|
97
101
|
{:else if content.length > 0}
|
|
@@ -8,6 +8,13 @@ export type MandatoryChatMessageProperties = {
|
|
|
8
8
|
export type OptionalChatMessageProperties = {
|
|
9
9
|
content?: string;
|
|
10
10
|
html?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Replaces the rendered bubble body with arbitrary markup while keeping the
|
|
13
|
+
* message chrome (role styling, avatar, header, attachments, copy/retry/
|
|
14
|
+
* feedback actions). Keep `content` populated with the text form so the copy
|
|
15
|
+
* action still has something to copy.
|
|
16
|
+
*/
|
|
17
|
+
body?: Snippet | null;
|
|
11
18
|
streaming?: boolean;
|
|
12
19
|
status?: ChatMessageStatus;
|
|
13
20
|
avatar?: Snippet;
|
|
@@ -13,12 +13,17 @@
|
|
|
13
13
|
let {
|
|
14
14
|
messages,
|
|
15
15
|
autoscroll = true,
|
|
16
|
+
scrollPolicy = 'near-bottom',
|
|
17
|
+
pinHold = false,
|
|
18
|
+
jump = true,
|
|
16
19
|
message,
|
|
20
|
+
messageBody,
|
|
17
21
|
messageAttachments,
|
|
18
22
|
empty,
|
|
19
23
|
jumpLabel = 'Jump to latest',
|
|
20
24
|
jumpIcon,
|
|
21
25
|
allowCopy = false,
|
|
26
|
+
onscrollstate,
|
|
22
27
|
onretry,
|
|
23
28
|
onfeedback,
|
|
24
29
|
testId,
|
|
@@ -26,7 +31,10 @@
|
|
|
26
31
|
}: ChatMessageListProperties = $props();
|
|
27
32
|
|
|
28
33
|
let listEl: HTMLElement | null = $state(null);
|
|
34
|
+
let innerEl: HTMLElement | null = $state(null);
|
|
29
35
|
let atBottom = $state(true);
|
|
36
|
+
let scrollable = $state(false);
|
|
37
|
+
let pinActive = false;
|
|
30
38
|
|
|
31
39
|
let scrollKey = $derived(`${messages.length}:${messages.at(-1)?.content.length ?? 0}`);
|
|
32
40
|
let showJump = $derived(!atBottom && messages.length > 0);
|
|
@@ -62,22 +70,103 @@
|
|
|
62
70
|
return node.scrollHeight - node.scrollTop - node.clientHeight < NEAR_BOTTOM_THRESHOLD;
|
|
63
71
|
}
|
|
64
72
|
|
|
73
|
+
function reportScrollState(node: HTMLElement): void {
|
|
74
|
+
atBottom = isNearBottom(node);
|
|
75
|
+
scrollable = node.scrollHeight - node.clientHeight > NEAR_BOTTOM_THRESHOLD;
|
|
76
|
+
onscrollstate?.({ atBottom, scrollable });
|
|
77
|
+
}
|
|
78
|
+
|
|
65
79
|
function handleScroll(event: Event & { currentTarget: HTMLElement }): void {
|
|
66
|
-
|
|
80
|
+
reportScrollState(event.currentTarget);
|
|
67
81
|
}
|
|
68
82
|
|
|
69
|
-
function scrollToBottom(): void {
|
|
83
|
+
export function scrollToBottom(): void {
|
|
70
84
|
if (listEl !== null) {
|
|
71
85
|
listEl.scrollTop = listEl.scrollHeight;
|
|
72
|
-
|
|
86
|
+
reportScrollState(listEl);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function lastSenderId(): string | null {
|
|
91
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
92
|
+
if (partyOf(messages[index].role) === 'sender') {
|
|
93
|
+
return messages[index].id;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* pin-sender-turn: reserve headroom below the newest sender message and scroll it
|
|
101
|
+
* to the top of the viewport, so the reply streams in beneath the question. The
|
|
102
|
+
* reservation is released when `pinHold` turns false (the host says the turn is
|
|
103
|
+
* over), collapsing the blank space a short reply would otherwise leave.
|
|
104
|
+
*
|
|
105
|
+
* Hosts often append the sender message TOGETHER with a streaming reply
|
|
106
|
+
* placeholder, so the pin targets the last sender message's row, not the last
|
|
107
|
+
* row. Rows map to messages by index — a custom `message` snippet must render
|
|
108
|
+
* exactly one root element per message for this policy.
|
|
109
|
+
*/
|
|
110
|
+
function pinLatestSenderMessage(): void {
|
|
111
|
+
if (listEl === null || innerEl === null) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
let senderIndex = -1;
|
|
115
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
116
|
+
if (partyOf(messages[index].role) === 'sender') {
|
|
117
|
+
senderIndex = index;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (senderIndex === -1) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const rows = Array.from(innerEl.children).filter((child) => !child.classList.contains('jump'));
|
|
125
|
+
const target = rows.length === messages.length ? rows[senderIndex] : rows[rows.length - 1];
|
|
126
|
+
if (!(target instanceof HTMLElement)) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const innerRect = innerEl.getBoundingClientRect();
|
|
130
|
+
const targetRect = target.getBoundingClientRect();
|
|
131
|
+
const rowOffsetInContent = targetRect.top - innerRect.top;
|
|
132
|
+
innerEl.style.minHeight = `${Math.ceil(rowOffsetInContent + listEl.clientHeight)}px`;
|
|
133
|
+
pinActive = true;
|
|
134
|
+
const listRect = listEl.getBoundingClientRect();
|
|
135
|
+
const paddingTop = Number.parseFloat(getComputedStyle(listEl).paddingTop) || 0;
|
|
136
|
+
listEl.scrollTop += targetRect.top - listRect.top - paddingTop;
|
|
137
|
+
reportScrollState(listEl);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function releasePin(): void {
|
|
141
|
+
if (innerEl !== null) {
|
|
142
|
+
innerEl.style.minHeight = '';
|
|
143
|
+
}
|
|
144
|
+
pinActive = false;
|
|
145
|
+
if (listEl !== null) {
|
|
146
|
+
reportScrollState(listEl);
|
|
73
147
|
}
|
|
74
148
|
}
|
|
75
149
|
|
|
76
150
|
const pinToBottom: Action<HTMLElement, string> = (node) => {
|
|
77
151
|
let previousCount = messages.length;
|
|
152
|
+
let previousSenderId = lastSenderId();
|
|
78
153
|
function scroll(): void {
|
|
79
154
|
const newMessage = messages.length > previousCount;
|
|
80
155
|
previousCount = messages.length;
|
|
156
|
+
if (scrollPolicy === 'pin-sender-turn') {
|
|
157
|
+
// Only a NEW sender message moves the viewport; streaming reply content
|
|
158
|
+
// grows below the pinned question without yanking the reader. Hosts often
|
|
159
|
+
// append the sender message together with a reply placeholder, so the
|
|
160
|
+
// trigger is the last SENDER id changing, not the last row's role.
|
|
161
|
+
const senderId = lastSenderId();
|
|
162
|
+
if (newMessage && senderId !== null && senderId !== previousSenderId) {
|
|
163
|
+
queueMicrotask(() => {
|
|
164
|
+
pinLatestSenderMessage();
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
previousSenderId = senderId;
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
81
170
|
if (autoscroll && (atBottom || newMessage)) {
|
|
82
171
|
queueMicrotask(() => {
|
|
83
172
|
node.scrollTop = node.scrollHeight;
|
|
@@ -87,6 +176,45 @@
|
|
|
87
176
|
scroll();
|
|
88
177
|
return { update: scroll };
|
|
89
178
|
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* A stateful `message`/`messageBody` snippet can grow without changing
|
|
182
|
+
* `messages.length` or the last message's `content.length`, so the scroll-key
|
|
183
|
+
* driven action never re-runs. Observing the inner wrapper's size keeps the
|
|
184
|
+
* near-bottom stick (and the reported scroll state) honest for custom bodies.
|
|
185
|
+
*/
|
|
186
|
+
const followContentGrowth: Action<HTMLElement> = (node) => {
|
|
187
|
+
if (typeof ResizeObserver === 'undefined') {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const observer = new ResizeObserver(() => {
|
|
191
|
+
if (listEl === null) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (scrollPolicy === 'near-bottom' && autoscroll && atBottom) {
|
|
195
|
+
listEl.scrollTop = listEl.scrollHeight;
|
|
196
|
+
}
|
|
197
|
+
reportScrollState(listEl);
|
|
198
|
+
});
|
|
199
|
+
observer.observe(node);
|
|
200
|
+
return {
|
|
201
|
+
destroy(): void {
|
|
202
|
+
observer.disconnect();
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const releaseOnHoldEnd: Action<HTMLElement, boolean> = () => {
|
|
208
|
+
let previousHold = pinHold;
|
|
209
|
+
function check(): void {
|
|
210
|
+
if (previousHold && !pinHold && pinActive) {
|
|
211
|
+
releasePin();
|
|
212
|
+
}
|
|
213
|
+
previousHold = pinHold;
|
|
214
|
+
}
|
|
215
|
+
check();
|
|
216
|
+
return { update: check };
|
|
217
|
+
};
|
|
90
218
|
</script>
|
|
91
219
|
|
|
92
220
|
<div
|
|
@@ -98,44 +226,51 @@
|
|
|
98
226
|
bind:this={listEl}
|
|
99
227
|
onscroll={handleScroll}
|
|
100
228
|
use:pinToBottom={scrollKey}
|
|
229
|
+
use:releaseOnHoldEnd={pinHold}
|
|
101
230
|
>
|
|
102
|
-
|
|
103
|
-
{
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
{#each messages as msg (msg.id)}
|
|
107
|
-
{#if typeof message === 'function'}
|
|
108
|
-
{@render message(msg)}
|
|
109
|
-
{:else}
|
|
110
|
-
{#snippet attachmentsFor()}
|
|
111
|
-
{@render messageAttachments?.(msg)}
|
|
112
|
-
{/snippet}
|
|
113
|
-
<ChatMessage
|
|
114
|
-
role={msg.role}
|
|
115
|
-
content={msg.content}
|
|
116
|
-
html={msg.html}
|
|
117
|
-
streaming={msg.streaming}
|
|
118
|
-
status={msg.status}
|
|
119
|
-
allowCopy={allowCopy && partyOf(msg.role) === 'responder'}
|
|
120
|
-
attachments={typeof messageAttachments === 'function' ? attachmentsFor : null}
|
|
121
|
-
onretry={retryFor(msg)}
|
|
122
|
-
onfeedback={feedbackFor(msg)}
|
|
123
|
-
/>
|
|
231
|
+
<div class="inner" bind:this={innerEl} use:followContentGrowth>
|
|
232
|
+
{#if messages.length === 0 && typeof empty === 'function'}
|
|
233
|
+
{@render empty()}
|
|
124
234
|
{/if}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
{#
|
|
131
|
-
{@render
|
|
132
|
-
{
|
|
133
|
-
|
|
134
|
-
{@
|
|
135
|
-
{/
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
235
|
+
|
|
236
|
+
{#each messages as msg (msg.id)}
|
|
237
|
+
{#if typeof message === 'function'}
|
|
238
|
+
{@render message(msg)}
|
|
239
|
+
{:else}
|
|
240
|
+
{#snippet attachmentsFor()}
|
|
241
|
+
{@render messageAttachments?.(msg)}
|
|
242
|
+
{/snippet}
|
|
243
|
+
{#snippet bodyFor()}
|
|
244
|
+
{@render messageBody?.(msg)}
|
|
245
|
+
{/snippet}
|
|
246
|
+
<ChatMessage
|
|
247
|
+
role={msg.role}
|
|
248
|
+
content={msg.content}
|
|
249
|
+
html={msg.html}
|
|
250
|
+
body={typeof messageBody === 'function' ? bodyFor : null}
|
|
251
|
+
streaming={msg.streaming}
|
|
252
|
+
status={msg.status}
|
|
253
|
+
allowCopy={allowCopy && partyOf(msg.role) === 'responder'}
|
|
254
|
+
attachments={typeof messageAttachments === 'function' ? attachmentsFor : null}
|
|
255
|
+
onretry={retryFor(msg)}
|
|
256
|
+
onfeedback={feedbackFor(msg)}
|
|
257
|
+
/>
|
|
258
|
+
{/if}
|
|
259
|
+
{/each}
|
|
260
|
+
|
|
261
|
+
{#if showJump && jump}
|
|
262
|
+
<div class="jump">
|
|
263
|
+
<Button onclick={scrollToBottom} ariaLabel={jumpLabel}>
|
|
264
|
+
{#if typeof jumpIcon === 'function'}
|
|
265
|
+
{@render jumpIcon()}
|
|
266
|
+
{:else}
|
|
267
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
268
|
+
{@html chevronDownSvg}
|
|
269
|
+
{/if}
|
|
270
|
+
</Button>
|
|
271
|
+
</div>
|
|
272
|
+
{/if}
|
|
273
|
+
</div>
|
|
139
274
|
</div>
|
|
140
275
|
|
|
141
276
|
<style>
|
|
@@ -143,7 +278,6 @@
|
|
|
143
278
|
box-sizing: border-box;
|
|
144
279
|
display: flex;
|
|
145
280
|
flex-direction: column;
|
|
146
|
-
gap: var(--chat-message-list-gap, 1rem);
|
|
147
281
|
flex: 1;
|
|
148
282
|
width: 100%;
|
|
149
283
|
overflow-y: auto;
|
|
@@ -151,6 +285,15 @@
|
|
|
151
285
|
scroll-behavior: var(--chat-message-list-scroll-behavior, smooth);
|
|
152
286
|
}
|
|
153
287
|
|
|
288
|
+
/* The inner wrapper is what the pin-sender-turn policy reserves height on; it
|
|
289
|
+
carries the column layout so the reservation becomes scrollable headroom. */
|
|
290
|
+
.inner {
|
|
291
|
+
display: flex;
|
|
292
|
+
flex-direction: column;
|
|
293
|
+
gap: var(--chat-message-list-gap, 1rem);
|
|
294
|
+
flex: 1 0 auto;
|
|
295
|
+
}
|
|
296
|
+
|
|
154
297
|
.jump {
|
|
155
298
|
position: sticky;
|
|
156
299
|
bottom: var(--chat-message-list-jump-bottom, 8px);
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { ChatMessageListProperties } from './properties';
|
|
2
|
-
declare const ChatMessageList: import("svelte").Component<ChatMessageListProperties, {
|
|
2
|
+
declare const ChatMessageList: import("svelte").Component<ChatMessageListProperties, {
|
|
3
|
+
scrollToBottom: () => void;
|
|
4
|
+
}, "">;
|
|
3
5
|
type ChatMessageList = ReturnType<typeof ChatMessageList>;
|
|
4
6
|
export default ChatMessageList;
|
|
@@ -7,7 +7,30 @@ export type MandatoryChatMessageListProperties = {
|
|
|
7
7
|
};
|
|
8
8
|
export type OptionalChatMessageListProperties = {
|
|
9
9
|
autoscroll?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* How the list follows new content. `near-bottom` (default) keeps the latest
|
|
12
|
+
* content in view while the reader is already near the bottom. `pin-sender-turn`
|
|
13
|
+
* is the conversational-AI pattern: each new sender message is pinned to the TOP
|
|
14
|
+
* of the viewport (headroom is reserved below it) so the reply streams into view
|
|
15
|
+
* beneath the question instead of yanking the reader to the bottom.
|
|
16
|
+
*/
|
|
17
|
+
scrollPolicy?: 'near-bottom' | 'pin-sender-turn';
|
|
18
|
+
/**
|
|
19
|
+
* pin-sender-turn only: while true, the headroom reserved for the current pinned
|
|
20
|
+
* turn is held. Drive it from the host's own "turn still busy" semantics
|
|
21
|
+
* (streaming, tool execution, awaiting confirmation…); when it turns false the
|
|
22
|
+
* reserved space collapses so a short reply leaves no blank gap.
|
|
23
|
+
*/
|
|
24
|
+
pinHold?: boolean;
|
|
25
|
+
/** Render the built-in jump-to-latest button (default true). Hosts with their own affordance pass false. */
|
|
26
|
+
jump?: boolean;
|
|
10
27
|
message?: Snippet<[ChatMessageData]>;
|
|
28
|
+
/**
|
|
29
|
+
* Renders inside each message's bubble in place of its text/html, keeping the
|
|
30
|
+
* full ChatMessage chrome. Lighter-weight than `message`, which replaces the
|
|
31
|
+
* entire ChatMessage; use `messageBody` when only the body is custom.
|
|
32
|
+
*/
|
|
33
|
+
messageBody?: Snippet<[ChatMessageData]>;
|
|
11
34
|
messageAttachments?: Snippet<[ChatMessageData]>;
|
|
12
35
|
empty?: Snippet;
|
|
13
36
|
jumpLabel?: string;
|
|
@@ -17,6 +40,15 @@ export type OptionalChatMessageListProperties = {
|
|
|
17
40
|
classes?: string;
|
|
18
41
|
};
|
|
19
42
|
export type ChatMessageListEventProperties = {
|
|
43
|
+
/**
|
|
44
|
+
* Reports the scroll state whenever it changes: whether the reader is at the
|
|
45
|
+
* bottom and whether the list overflows at all — for hosts that place their own
|
|
46
|
+
* jump-to-latest affordance outside the list.
|
|
47
|
+
*/
|
|
48
|
+
onscrollstate?: (state: {
|
|
49
|
+
atBottom: boolean;
|
|
50
|
+
scrollable: boolean;
|
|
51
|
+
}) => void;
|
|
20
52
|
onretry?: () => void;
|
|
21
53
|
onfeedback?: (value: ChatMessageFeedback, message: ChatMessageData) => void;
|
|
22
54
|
};
|