@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.
- 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/dist/Table/Table.svelte +35 -3
- 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
|
};
|
package/dist/Table/Table.svelte
CHANGED
|
@@ -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
|
-
|
|
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
|
|
850
|
+
<span
|
|
851
|
+
class="table-inline-search-trigger"
|
|
852
|
+
bind:this={inlineSearchTriggerRef}
|
|
853
|
+
>
|
|
822
854
|
<Button
|
|
823
855
|
variant="ghost"
|
|
824
856
|
iconOnly={true}
|