@juspay/svelte-ui-components 2.129.0 → 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.
@@ -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
  };
@@ -171,6 +171,7 @@
171
171
  let isServerSearch = $derived(typeof onSearchChange === 'function');
172
172
  let searchInputRef = $state<HTMLInputElement | null>(null);
173
173
  let inlineSearchInputRef = $state<{ focus: () => void } | null>(null);
174
+ let inlineSearchTriggerRef = $state<HTMLElement | null>(null);
174
175
  let isInlineSearch = $derived(searchConfig?.displayMode === 'inline');
175
176
  let isInlineSearchExpanded = $state(false);
176
177
 
@@ -191,8 +192,12 @@
191
192
  };
192
193
 
193
194
  const clearSearch = (): void => {
195
+ const wasInlineExpanded = isInlineSearchExpanded;
194
196
  updateSearch('');
195
197
  isInlineSearchExpanded = false;
198
+ if (wasInlineExpanded) {
199
+ focusInlineSearchTrigger();
200
+ }
196
201
  };
197
202
 
198
203
  const expandInlineSearch = (): void => {
@@ -200,8 +205,31 @@
200
205
  queueMicrotask(() => inlineSearchInputRef?.focus());
201
206
  };
202
207
 
208
+ // Mirror of expandInlineSearch: collapsing unmounts the input, so without an
209
+ // explicit restore the focus falls to the document and a keyboard user loses
210
+ // their place in the table. The trigger only exists once the collapse has
211
+ // rendered, hence the microtask.
212
+ const focusInlineSearchTrigger = (): void => {
213
+ queueMicrotask(() =>
214
+ inlineSearchTriggerRef?.querySelector<HTMLButtonElement>('button')?.focus()
215
+ );
216
+ };
217
+
218
+ // The expanded guard matters: collapsing unmounts the focused input, which can
219
+ // fire blur on the way out. Without it, an Escape (or close-button) collapse
220
+ // re-enters clearSearch and fires a second onSearchChange('') — a duplicate
221
+ // request on the server-search path.
203
222
  const collapseInlineSearchIfEmpty = (): void => {
204
- if (searchTerm.trim() === '') {
223
+ if (isInlineSearchExpanded && searchTerm.trim() === '') {
224
+ clearSearch();
225
+ }
226
+ };
227
+
228
+ // Escape is the expected way out of an expand-in-place search; without it the
229
+ // only exits are the close button and blurring while already empty.
230
+ const handleInlineSearchKeyDown = (event: KeyboardEvent): void => {
231
+ if (event.key === 'Escape') {
232
+ event.stopPropagation();
205
233
  clearSearch();
206
234
  }
207
235
  };
@@ -795,13 +823,14 @@
795
823
  {#if isInlineSearchExpanded}
796
824
  <Input
797
825
  bind:this={inlineSearchInputRef}
798
- bind:value={searchTerm}
826
+ value={searchTerm}
799
827
  placeholder={searchConfig?.placeholder ?? 'Search…'}
800
828
  testId={searchConfig?.testId ?? ''}
801
829
  ariaLabel={searchConfig?.placeholder ?? 'Search'}
802
830
  autoComplete="off"
803
831
  onInput={(value: string) => updateSearch(value)}
804
832
  onBlur={collapseInlineSearchIfEmpty}
833
+ onKeyDown={handleInlineSearchKeyDown}
805
834
  classes="table-inline-search-input"
806
835
  />
807
836
  <span class="table-inline-search-clear">
@@ -818,7 +847,10 @@
818
847
  </Button>
819
848
  </span>
820
849
  {:else}
821
- <span class="table-inline-search-trigger">
850
+ <span
851
+ class="table-inline-search-trigger"
852
+ bind:this={inlineSearchTriggerRef}
853
+ >
822
854
  <Button
823
855
  variant="ghost"
824
856
  iconOnly={true}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/svelte-ui-components",
3
- "version": "2.129.0",
3
+ "version": "2.130.0",
4
4
  "description": "A themeable Svelte 5 UI component library with CSS custom property driven styling",
5
5
  "keywords": [
6
6
  "svelte",