@juspay/svelte-ui-components 2.129.1 → 2.130.1

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.
@@ -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 hasContent = $derived(hasHtml || content.length > 0);
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 hasHtml}
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
- atBottom = isNearBottom(event.currentTarget);
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
- atBottom = true;
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
- {#if messages.length === 0 && typeof empty === 'function'}
103
- {@render empty()}
104
- {/if}
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
- {/each}
126
-
127
- {#if showJump}
128
- <div class="jump">
129
- <Button onclick={scrollToBottom} ariaLabel={jumpLabel}>
130
- {#if typeof jumpIcon === 'function'}
131
- {@render jumpIcon()}
132
- {:else}
133
- <!-- eslint-disable-next-line svelte/no-at-html-tags -->
134
- {@html chevronDownSvg}
135
- {/if}
136
- </Button>
137
- </div>
138
- {/if}
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/svelte-ui-components",
3
- "version": "2.129.1",
3
+ "version": "2.130.1",
4
4
  "description": "A themeable Svelte 5 UI component library with CSS custom property driven styling",
5
5
  "keywords": [
6
6
  "svelte",