@juspay/svelte-ui-components 4.33.3 → 4.34.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.
- package/dist/Accordion/Accordion.svelte +26 -2
- package/dist/Accordion/properties.d.ts +7 -0
- package/dist/ArmedButton/ArmedButton.svelte +147 -0
- package/dist/ArmedButton/ArmedButton.svelte.d.ts +4 -0
- package/dist/ArmedButton/properties.d.ts +24 -0
- package/dist/ArmedButton/properties.js +1 -0
- package/dist/Banner/Banner.svelte +13 -8
- package/dist/Banner/properties.d.ts +8 -1
- package/dist/Button/Button.svelte +26 -0
- package/dist/Button/properties.d.ts +17 -0
- package/dist/ChatComposer/ChatComposer.svelte +314 -1
- package/dist/ChatComposer/properties.d.ts +42 -0
- package/dist/ChatMessageList/ChatMessageList.svelte +22 -2
- package/dist/ChatMessageList/ChatMessageList.svelte.d.ts +1 -1
- package/dist/CommandMenu/CommandMenu.svelte +53 -20
- package/dist/CommandMenu/properties.d.ts +24 -0
- package/dist/DiffViewer/DiffViewer.svelte +210 -0
- package/dist/DiffViewer/DiffViewer.svelte.d.ts +4 -0
- package/dist/DiffViewer/properties.d.ts +25 -0
- package/dist/DiffViewer/properties.js +1 -0
- package/dist/EmptyState/EmptyState.svelte +22 -3
- package/dist/EmptyState/properties.d.ts +9 -0
- package/dist/HITL/HITL.svelte +320 -51
- package/dist/HITL/properties.d.ts +113 -11
- package/dist/Input/Input.svelte +45 -1
- package/dist/Input/properties.d.ts +15 -0
- package/dist/KeyValue/KeyValue.svelte +16 -3
- package/dist/KeyValue/properties.d.ts +2 -0
- package/dist/Progress/Progress.svelte +96 -41
- package/dist/Progress/properties.d.ts +19 -0
- package/dist/Snippet/Snippet.svelte +45 -1
- package/dist/Snippet/properties.d.ts +8 -0
- package/dist/Tabs/Tabs.svelte +75 -16
- package/dist/Tabs/properties.d.ts +21 -0
- package/dist/ThinkingIndicator/ThinkingIndicator.svelte.d.ts +1 -1
- package/dist/Toolbar/Toolbar.svelte +81 -3
- package/dist/Toolbar/properties.d.ts +37 -0
- package/dist/WindowedList/WindowedList.svelte +129 -0
- package/dist/WindowedList/WindowedList.svelte.d.ts +25 -0
- package/dist/WindowedList/properties.d.ts +38 -0
- package/dist/WindowedList/properties.js +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/styles/theme-dark.css +42 -0
- package/dist-wc/index.js +2637 -1642
- package/package.json +2 -1
|
@@ -6,8 +6,14 @@
|
|
|
6
6
|
import stopSvg from '../assets/stop.svg?raw';
|
|
7
7
|
import micSvg from '../assets/mic.svg?raw';
|
|
8
8
|
import attachSvg from '../assets/attach.svg?raw';
|
|
9
|
+
import { tick } from 'svelte';
|
|
9
10
|
import type { Action } from 'svelte/action';
|
|
10
|
-
import
|
|
11
|
+
import { registerDismissible } from '../_interaction/dismissal';
|
|
12
|
+
import type {
|
|
13
|
+
ChatComposerProperties,
|
|
14
|
+
ChatComposerSlashCommand,
|
|
15
|
+
ChatComposerSlashCommandChoice
|
|
16
|
+
} from './properties';
|
|
11
17
|
import { resolveControlDisabled } from './controlDisabled';
|
|
12
18
|
import { shouldApplyClear } from './submitResult';
|
|
13
19
|
import { normalizeDictationState } from './dictationState';
|
|
@@ -54,6 +60,13 @@
|
|
|
54
60
|
leading,
|
|
55
61
|
statusText,
|
|
56
62
|
statusTestId,
|
|
63
|
+
slashCommands = null,
|
|
64
|
+
slashMenuAriaLabel = 'slash commands',
|
|
65
|
+
slashMenuHint = '↑↓ move · tab complete · ↵ select · esc dismiss',
|
|
66
|
+
slashMenuClasses,
|
|
67
|
+
slashItemClasses,
|
|
68
|
+
slashSelectedItemClasses,
|
|
69
|
+
slashItem,
|
|
57
70
|
onsubmit,
|
|
58
71
|
oninput,
|
|
59
72
|
onkeydown,
|
|
@@ -103,10 +116,159 @@
|
|
|
103
116
|
(sendable ?? (value.trim().length > 0 || attachments.length > 0 || hasRichAttachments))
|
|
104
117
|
);
|
|
105
118
|
|
|
119
|
+
// ---- slash-command menu (off unless `slashCommands` is set) ----
|
|
120
|
+
// Stage one: "/co" lists the commands that match. Stage two: "/model son"
|
|
121
|
+
// lists the /model command's choices that start with "son". A free-text
|
|
122
|
+
// argument dismisses the menu at the space instead.
|
|
123
|
+
let slashDismissed = $state(false);
|
|
124
|
+
let slashSelected = $state(0);
|
|
125
|
+
let slashList: HTMLUListElement | null = $state(null);
|
|
126
|
+
|
|
127
|
+
const slashMatches = $derived.by((): ChatComposerSlashCommand[] => {
|
|
128
|
+
if (slashCommands === null || slashDismissed || !/^\/\S*$/.test(value)) {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
const query = value.toLowerCase();
|
|
132
|
+
return slashCommands.filter(
|
|
133
|
+
(command) =>
|
|
134
|
+
command.name.toLowerCase().startsWith(query) ||
|
|
135
|
+
(query.length > 1 && (command.description ?? '').toLowerCase().includes(query.slice(1)))
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
const slashArg = $derived.by(
|
|
139
|
+
(): { command: ChatComposerSlashCommand; choices: ChatComposerSlashCommandChoice[] } | null => {
|
|
140
|
+
if (slashCommands === null || slashDismissed) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const parsed = /^(\/\S+)\s(\S*)$/.exec(value);
|
|
144
|
+
if (parsed === null) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
const name = (parsed[1] ?? '').toLowerCase();
|
|
148
|
+
const command = slashCommands.find(
|
|
149
|
+
(entry) => entry.name.toLowerCase() === name && (entry.choices?.length ?? 0) > 0
|
|
150
|
+
);
|
|
151
|
+
if (typeof command !== 'object') {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
const partial = (parsed[2] ?? '').toLowerCase();
|
|
155
|
+
return {
|
|
156
|
+
command,
|
|
157
|
+
choices: (command.choices ?? []).filter((choice) =>
|
|
158
|
+
choice.value.toLowerCase().startsWith(partial)
|
|
159
|
+
)
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
);
|
|
163
|
+
// One list, two stages: stage two shows the choices as rows.
|
|
164
|
+
const slashItems = $derived.by((): ChatComposerSlashCommand[] =>
|
|
165
|
+
slashArg === null
|
|
166
|
+
? slashMatches
|
|
167
|
+
: slashArg.choices.map((choice) =>
|
|
168
|
+
typeof choice.description === 'string'
|
|
169
|
+
? { name: choice.value, description: choice.description }
|
|
170
|
+
: { name: choice.value }
|
|
171
|
+
)
|
|
172
|
+
);
|
|
173
|
+
const slashOpen = $derived(slashItems.length > 0);
|
|
174
|
+
// The highlight stays inside the list as typing narrows it.
|
|
175
|
+
const slashActive = $derived(Math.min(slashSelected, Math.max(0, slashItems.length - 1)));
|
|
176
|
+
|
|
177
|
+
// Combobox wiring for the textarea, same pattern as CommandMenu's input: the menu's own id
|
|
178
|
+
// for aria-controls, and the highlighted row's id for aria-activedescendant. Both ids --
|
|
179
|
+
// and the role/aria-expanded/aria-autocomplete below -- are applied to the textarea only
|
|
180
|
+
// while slashCommands is set, so a consumer not using slash commands keeps the plain
|
|
181
|
+
// <textarea> role it always had.
|
|
182
|
+
const uid = $props.id();
|
|
183
|
+
const slashListboxId = `chat-composer-slash-listbox-${uid}`;
|
|
184
|
+
const slashActiveId = $derived(
|
|
185
|
+
slashOpen ? `chat-composer-slash-option-${uid}-${slashActive}` : null
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
// What the highlighted row completes the input to, stage-aware.
|
|
189
|
+
function slashCompletion(index: number): string | null {
|
|
190
|
+
if (slashArg !== null) {
|
|
191
|
+
const choice = slashArg.choices.at(index);
|
|
192
|
+
return typeof choice === 'object' ? `${slashArg.command.name} ${choice.value}` : null;
|
|
193
|
+
}
|
|
194
|
+
return slashItems.at(index)?.name ?? null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function pickSlash(index: number): void {
|
|
198
|
+
if (slashArg !== null) {
|
|
199
|
+
const choice = slashArg.choices.at(index);
|
|
200
|
+
if (typeof choice === 'object') {
|
|
201
|
+
value = `${slashArg.command.name} ${choice.value}`;
|
|
202
|
+
slashSelected = 0;
|
|
203
|
+
}
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const command = slashMatches.at(index);
|
|
207
|
+
if (typeof command !== 'object') {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
// A command that takes arguments gets a trailing space and waits for
|
|
211
|
+
// them; with enumerable choices the menu stays open for stage two.
|
|
212
|
+
value = `${command.name} `;
|
|
213
|
+
slashDismissed = (command.choices?.length ?? 0) === 0;
|
|
214
|
+
slashSelected = 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Keeps the highlighted row in view while the list scrolls.
|
|
218
|
+
function revealSlash(): void {
|
|
219
|
+
void tick().then(() => {
|
|
220
|
+
const row = slashList?.children.item(slashActive);
|
|
221
|
+
row?.scrollIntoView({ block: 'nearest' });
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Arrows, Tab and Enter while the menu is open. Escape is the dismissible
|
|
226
|
+
// stack's (registered while the menu is open), so an overlay stacked above
|
|
227
|
+
// the composer takes it first.
|
|
228
|
+
function handleSlashKey(event: KeyboardEvent): boolean {
|
|
229
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
230
|
+
event.preventDefault();
|
|
231
|
+
slashSelected =
|
|
232
|
+
event.key === 'ArrowDown'
|
|
233
|
+
? Math.min(slashActive + 1, slashItems.length - 1)
|
|
234
|
+
: Math.max(slashActive - 1, 0);
|
|
235
|
+
revealSlash();
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
if (event.key === 'Tab' && !event.shiftKey) {
|
|
239
|
+
event.preventDefault();
|
|
240
|
+
pickSlash(slashActive);
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
|
244
|
+
event.preventDefault();
|
|
245
|
+
// Enter on a complete match submits it; otherwise it completes and
|
|
246
|
+
// the next Enter submits.
|
|
247
|
+
if (value.trim() === slashCompletion(slashActive)) {
|
|
248
|
+
submit();
|
|
249
|
+
} else {
|
|
250
|
+
pickSlash(slashActive);
|
|
251
|
+
}
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function slashDismissal(_node: HTMLDivElement) {
|
|
258
|
+
const release = registerDismissible({
|
|
259
|
+
element: () => _node,
|
|
260
|
+
onEscape: () => {
|
|
261
|
+
slashDismissed = true;
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
return { destroy: release };
|
|
265
|
+
}
|
|
266
|
+
|
|
106
267
|
function submit(): void {
|
|
107
268
|
if (!canSend) {
|
|
108
269
|
return;
|
|
109
270
|
}
|
|
271
|
+
slashDismissed = false;
|
|
110
272
|
const submitted = { value, attachments };
|
|
111
273
|
const result = onsubmit?.(submitted.value, submitted.attachments);
|
|
112
274
|
if (result instanceof Promise) {
|
|
@@ -132,7 +294,9 @@
|
|
|
132
294
|
}
|
|
133
295
|
|
|
134
296
|
function handleKeydown(event: KeyboardEvent): void {
|
|
297
|
+
const slashHandled = slashOpen && handleSlashKey(event);
|
|
135
298
|
if (
|
|
299
|
+
!slashHandled &&
|
|
136
300
|
submitOnEnter &&
|
|
137
301
|
event.key === 'Enter' &&
|
|
138
302
|
!event.shiftKey &&
|
|
@@ -153,6 +317,9 @@
|
|
|
153
317
|
}
|
|
154
318
|
|
|
155
319
|
function handleInput(event: Event & { currentTarget: HTMLTextAreaElement }): void {
|
|
320
|
+
// A fresh edit un-dismisses the slash menu and resets its highlight.
|
|
321
|
+
slashDismissed = false;
|
|
322
|
+
slashSelected = 0;
|
|
156
323
|
oninput?.(event.currentTarget.value, event);
|
|
157
324
|
}
|
|
158
325
|
|
|
@@ -182,9 +349,62 @@
|
|
|
182
349
|
<div
|
|
183
350
|
class="chat-composer {classes ?? ''}"
|
|
184
351
|
class:disabled
|
|
352
|
+
data-has-slash={slashCommands !== null ? '' : null}
|
|
185
353
|
data-pw={typeof testId === 'string' ? testId : null}
|
|
186
354
|
testID={typeof testId === 'string' ? testId : null}
|
|
187
355
|
>
|
|
356
|
+
{#if slashOpen}
|
|
357
|
+
<div
|
|
358
|
+
class="slash-menu {slashMenuClasses ?? ''}"
|
|
359
|
+
role="listbox"
|
|
360
|
+
id={slashListboxId}
|
|
361
|
+
aria-label={slashMenuAriaLabel}
|
|
362
|
+
use:slashDismissal
|
|
363
|
+
>
|
|
364
|
+
<!-- role="presentation": a listbox's accessible children must be its own role=option
|
|
365
|
+
rows. Left as a plain <ul>, this list's implicit role=list would sit between the
|
|
366
|
+
listbox and its options in the accessibility tree, breaking that ownership chain
|
|
367
|
+
for assistive tech that computes it strictly from the DOM. -->
|
|
368
|
+
<ul class="slash-list" role="presentation" bind:this={slashList}>
|
|
369
|
+
{#each slashItems as command, index (command.name)}
|
|
370
|
+
<!-- The row, not the Button, takes the hover: Button has no
|
|
371
|
+
mouseenter, and it fills the row, so it is the same gesture. -->
|
|
372
|
+
<li
|
|
373
|
+
id={`chat-composer-slash-option-${uid}-${index}`}
|
|
374
|
+
role="option"
|
|
375
|
+
aria-selected={index === slashActive}
|
|
376
|
+
onmouseenter={() => (slashSelected = index)}
|
|
377
|
+
class:slash-item-selected-default={index === slashActive &&
|
|
378
|
+
typeof slashSelectedItemClasses !== 'string'}
|
|
379
|
+
>
|
|
380
|
+
<Button
|
|
381
|
+
type="button"
|
|
382
|
+
classes={[slashItemClasses, index === slashActive ? slashSelectedItemClasses : '']
|
|
383
|
+
.filter((value) => typeof value === 'string' && value.length > 0)
|
|
384
|
+
.join(' ')}
|
|
385
|
+
onclick={() => pickSlash(index)}
|
|
386
|
+
>
|
|
387
|
+
{#if typeof slashItem === 'function'}
|
|
388
|
+
{@render slashItem(command, index === slashActive)}
|
|
389
|
+
{:else}
|
|
390
|
+
<span class="slash-name">{command.name}</span>
|
|
391
|
+
{#if typeof command.argumentHint === 'string'}
|
|
392
|
+
<span class="slash-hint">{command.argumentHint}</span>
|
|
393
|
+
{/if}
|
|
394
|
+
{#if typeof command.description === 'string'}
|
|
395
|
+
<span class="slash-desc">{command.description}</span>
|
|
396
|
+
{/if}
|
|
397
|
+
{#if typeof command.badge === 'string'}
|
|
398
|
+
<span class="slash-badge">{command.badge}</span>
|
|
399
|
+
{/if}
|
|
400
|
+
{/if}
|
|
401
|
+
</Button>
|
|
402
|
+
</li>
|
|
403
|
+
{/each}
|
|
404
|
+
</ul>
|
|
405
|
+
<div class="slash-foot">{slashMenuHint}</div>
|
|
406
|
+
</div>
|
|
407
|
+
{/if}
|
|
188
408
|
{#if typeof statusText === 'string'}
|
|
189
409
|
<!-- Generic region, role and politeness only -- the sentence is
|
|
190
410
|
whatever the caller passed in `statusText`, never hardcoded here. -->
|
|
@@ -268,6 +488,11 @@
|
|
|
268
488
|
disabled={resolvedTextDisabled}
|
|
269
489
|
rows="1"
|
|
270
490
|
aria-label={inputAriaLabel ?? (placeholder.length > 0 ? placeholder : 'Message')}
|
|
491
|
+
role={slashCommands !== null ? 'combobox' : null}
|
|
492
|
+
aria-expanded={slashCommands !== null ? slashOpen : null}
|
|
493
|
+
aria-controls={slashOpen ? slashListboxId : null}
|
|
494
|
+
aria-autocomplete={slashCommands !== null ? 'list' : null}
|
|
495
|
+
aria-activedescendant={slashActiveId}
|
|
271
496
|
maxlength={maxLength > 0 ? maxLength : null}
|
|
272
497
|
oninput={handleInput}
|
|
273
498
|
onkeydown={handleKeydown}
|
|
@@ -463,6 +688,94 @@
|
|
|
463
688
|
--button-text-color: var(--chat-composer-stop-color, #ffffff);
|
|
464
689
|
}
|
|
465
690
|
|
|
691
|
+
/* Positioned only when slash commands are on, so their menu can sit
|
|
692
|
+
above the composer; a consumer without them is unchanged. A data attribute,
|
|
693
|
+
not a class, and the menu rules below are written down the structure they live in
|
|
694
|
+
(.chat-composer > .slash-menu ...): a class directive would strip a `has-slash` class a
|
|
695
|
+
consumer already passes through `classes`, and a class of one of these names passed
|
|
696
|
+
there would match rules meant for the menu. */
|
|
697
|
+
.chat-composer[data-has-slash] {
|
|
698
|
+
position: relative;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/* Positioned from the composer's padding box; the outset (e.g. the
|
|
702
|
+
composer's border width) lines it up with the outer edge instead. */
|
|
703
|
+
.chat-composer > .slash-menu {
|
|
704
|
+
position: absolute;
|
|
705
|
+
bottom: calc(100% + var(--chat-composer-slash-menu-outset, 0px));
|
|
706
|
+
left: calc(-1 * var(--chat-composer-slash-menu-outset, 0px));
|
|
707
|
+
right: calc(-1 * var(--chat-composer-slash-menu-outset, 0px));
|
|
708
|
+
margin-bottom: var(--chat-composer-slash-menu-gap, 8px);
|
|
709
|
+
overflow: hidden;
|
|
710
|
+
z-index: var(--chat-composer-slash-menu-z-index, 30);
|
|
711
|
+
background: var(--chat-composer-slash-menu-background, #ffffff);
|
|
712
|
+
border: var(--chat-composer-slash-menu-border, 1px solid #e4e4e7);
|
|
713
|
+
border-radius: var(--chat-composer-slash-menu-border-radius, 12px);
|
|
714
|
+
box-shadow: var(--chat-composer-slash-menu-box-shadow, 0 8px 24px rgb(0 0 0 / 12%));
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
.slash-menu > .slash-list {
|
|
718
|
+
list-style: none;
|
|
719
|
+
margin: 0;
|
|
720
|
+
padding: var(--chat-composer-slash-list-padding, 4px);
|
|
721
|
+
max-height: var(--chat-composer-slash-list-max-height, 280px);
|
|
722
|
+
overflow-y: auto;
|
|
723
|
+
display: grid;
|
|
724
|
+
gap: var(--chat-composer-slash-list-gap, 2px);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
.slash-menu .slash-list li {
|
|
728
|
+
min-width: 0;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
.slash-menu > .slash-foot {
|
|
732
|
+
padding: var(--chat-composer-slash-foot-padding, 4px 12px);
|
|
733
|
+
border-top: var(--chat-composer-slash-foot-border, 1px solid #e4e4e7);
|
|
734
|
+
font-family: var(--chat-composer-slash-foot-font-family, inherit);
|
|
735
|
+
font-variant-ligatures: var(--chat-composer-slash-foot-font-variant-ligatures, inherit);
|
|
736
|
+
font-size: var(--chat-composer-slash-foot-font-size, 12px);
|
|
737
|
+
color: var(--chat-composer-slash-foot-color, #71717a);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/* The default row content (a consumer's slashItem snippet replaces it). */
|
|
741
|
+
.slash-menu .slash-name {
|
|
742
|
+
flex-shrink: 0;
|
|
743
|
+
font-family: var(--chat-composer-slash-name-font-family, monospace);
|
|
744
|
+
color: var(--chat-composer-slash-name-color, #2563eb);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
.slash-menu .slash-hint {
|
|
748
|
+
flex-shrink: 0;
|
|
749
|
+
font-size: var(--chat-composer-slash-hint-font-size, 12px);
|
|
750
|
+
color: var(--chat-composer-slash-hint-color, #71717a);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
.slash-menu .slash-desc {
|
|
754
|
+
flex: 1;
|
|
755
|
+
min-width: 0;
|
|
756
|
+
overflow: hidden;
|
|
757
|
+
text-overflow: ellipsis;
|
|
758
|
+
white-space: nowrap;
|
|
759
|
+
color: var(--chat-composer-slash-desc-color, #52525b);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/* Default visual for the arrow-key-highlighted row when a consumer hasn't
|
|
763
|
+
opted into their own selected-state look via slashSelectedItemClasses --
|
|
764
|
+
without this, the only signal was aria-selected, screen-reader-only.
|
|
765
|
+
Scoped so a consumer's own class stays authoritative the moment it's
|
|
766
|
+
supplied: this rule simply never applies then. */
|
|
767
|
+
.slash-menu .slash-item-selected-default {
|
|
768
|
+
background: var(--chat-composer-slash-item-selected-background, #f4f4f5);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
.slash-menu .slash-badge {
|
|
772
|
+
padding: var(--chat-composer-slash-badge-padding, 2px 8px);
|
|
773
|
+
border-radius: var(--chat-composer-slash-badge-border-radius, 999px);
|
|
774
|
+
font-size: var(--chat-composer-slash-badge-font-size, 12px);
|
|
775
|
+
background: var(--chat-composer-slash-badge-background, #eff6ff);
|
|
776
|
+
color: var(--chat-composer-slash-badge-color, #2563eb);
|
|
777
|
+
}
|
|
778
|
+
|
|
466
779
|
/* Visually hidden but still read by assistive technology -- same pattern
|
|
467
780
|
Table's caption and Label's required marker use. */
|
|
468
781
|
.sr-only {
|
|
@@ -92,6 +92,31 @@ export type OptionalChatComposerProperties = {
|
|
|
92
92
|
statusText?: string;
|
|
93
93
|
/** Test id on the status region (see `statusText`). */
|
|
94
94
|
statusTestId?: string;
|
|
95
|
+
/**
|
|
96
|
+
* Opt-in slash-command menu. With commands passed, typing "/" opens a
|
|
97
|
+
* listbox of the ones that match (name prefix, or the text after "/" found
|
|
98
|
+
* in a description); arrows move, Tab completes, Enter picks (Enter on a
|
|
99
|
+
* complete match submits), Escape dismisses -- through the shared
|
|
100
|
+
* dismissible stack, so an overlay above the composer closes first. A
|
|
101
|
+
* command with `choices` keeps the menu open past the space for a second
|
|
102
|
+
* stage listing them. `null` (the default) turns the whole feature off.
|
|
103
|
+
*/
|
|
104
|
+
slashCommands?: ChatComposerSlashCommand[] | null;
|
|
105
|
+
/** Accessible name of the slash-command listbox. Defaults to 'slash commands'. */
|
|
106
|
+
slashMenuAriaLabel?: string;
|
|
107
|
+
/** Key-hint line under the slash-command list. */
|
|
108
|
+
slashMenuHint?: string;
|
|
109
|
+
/** Class(es) on the slash-command panel (e.g. the consumer's own panel look). */
|
|
110
|
+
slashMenuClasses?: string;
|
|
111
|
+
/** Class(es) on every slash-command row's Button. */
|
|
112
|
+
slashItemClasses?: string;
|
|
113
|
+
/** Class(es) added to the highlighted slash-command row's Button. */
|
|
114
|
+
slashSelectedItemClasses?: string;
|
|
115
|
+
/**
|
|
116
|
+
* Renders a slash-command row's content (the command, whether it is
|
|
117
|
+
* highlighted). Omitted: name, argument hint, description and badge.
|
|
118
|
+
*/
|
|
119
|
+
slashItem?: Snippet<[ChatComposerSlashCommand, boolean]>;
|
|
95
120
|
testId?: string;
|
|
96
121
|
/** Test id emitted as `data-pw` on the textarea itself (none by default). */
|
|
97
122
|
inputTestId?: string;
|
|
@@ -156,3 +181,20 @@ export type ChatComposerEventProperties = {
|
|
|
156
181
|
*/
|
|
157
182
|
onaction?: () => void;
|
|
158
183
|
};
|
|
184
|
+
/** A value a slash command's argument can take (its second menu stage). */
|
|
185
|
+
export type ChatComposerSlashCommandChoice = {
|
|
186
|
+
value: string;
|
|
187
|
+
description?: string;
|
|
188
|
+
};
|
|
189
|
+
/** One entry of ChatComposer's slash-command menu. */
|
|
190
|
+
export type ChatComposerSlashCommand = {
|
|
191
|
+
/** Including the slash, e.g. "/model". */
|
|
192
|
+
name: string;
|
|
193
|
+
description?: string;
|
|
194
|
+
/** Shown after the name, e.g. "[model]". */
|
|
195
|
+
argumentHint?: string;
|
|
196
|
+
/** Enumerable argument values: picking the command lists these next. */
|
|
197
|
+
choices?: ChatComposerSlashCommandChoice[];
|
|
198
|
+
/** A short tag on the row, e.g. where the command comes from. */
|
|
199
|
+
badge?: string;
|
|
200
|
+
};
|
|
@@ -85,10 +85,30 @@
|
|
|
85
85
|
reportScrollState(event.currentTarget);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
/**
|
|
89
|
+
* `behavior`, when supplied, sets the list's inline `scroll-behavior` for
|
|
90
|
+
* just this call (outranking, not changing, its custom-property default),
|
|
91
|
+
* then clears it on the next frame so it cannot linger onto a later scroll
|
|
92
|
+
* this function did not cause. The CSS property has no `instant` keyword --
|
|
93
|
+
* the browser silently drops it -- so `'instant'` is written as `auto`, and
|
|
94
|
+
* reduced-motion turns `'smooth'` into `auto` as well.
|
|
95
|
+
*/
|
|
96
|
+
export function scrollToBottom(behavior?: 'smooth' | 'instant'): void {
|
|
89
97
|
if (listEl !== null) {
|
|
98
|
+
const hasBehavior = typeof behavior === 'string';
|
|
99
|
+
if (hasBehavior) {
|
|
100
|
+
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
101
|
+
listEl.style.scrollBehavior =
|
|
102
|
+
behavior === 'smooth' && !prefersReducedMotion ? 'smooth' : 'auto';
|
|
103
|
+
}
|
|
90
104
|
listEl.scrollTop = listEl.scrollHeight;
|
|
91
105
|
reportScrollState(listEl);
|
|
106
|
+
if (hasBehavior) {
|
|
107
|
+
const node = listEl;
|
|
108
|
+
requestAnimationFrame(() => {
|
|
109
|
+
node.style.scrollBehavior = '';
|
|
110
|
+
});
|
|
111
|
+
}
|
|
92
112
|
}
|
|
93
113
|
}
|
|
94
114
|
|
|
@@ -316,7 +336,7 @@
|
|
|
316
336
|
|
|
317
337
|
{#if showJump && jump}
|
|
318
338
|
<div class="jump">
|
|
319
|
-
<Button onclick={scrollToBottom} ariaLabel={jumpLabel}>
|
|
339
|
+
<Button onclick={() => scrollToBottom()} ariaLabel={jumpLabel}>
|
|
320
340
|
{#if typeof jumpIcon === 'function'}
|
|
321
341
|
{@render jumpIcon()}
|
|
322
342
|
{:else}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ChatMessageListProperties } from './properties';
|
|
2
2
|
declare const ChatMessageList: import("svelte").Component<ChatMessageListProperties, {
|
|
3
|
-
scrollToBottom: () => void;
|
|
3
|
+
scrollToBottom: (behavior?: "smooth" | "instant") => void;
|
|
4
4
|
}, "">;
|
|
5
5
|
type ChatMessageList = ReturnType<typeof ChatMessageList>;
|
|
6
6
|
export default ChatMessageList;
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
import Img from '../Img/Img.svelte';
|
|
10
10
|
import searchSvg from '../assets/search.svg?raw';
|
|
11
11
|
|
|
12
|
+
function defaultFilter(item: CommandItem, needle: string): boolean {
|
|
13
|
+
return item.label.toLowerCase().includes(needle.toLowerCase());
|
|
14
|
+
}
|
|
15
|
+
|
|
12
16
|
let {
|
|
13
17
|
items,
|
|
14
18
|
open = $bindable(false),
|
|
@@ -16,10 +20,15 @@
|
|
|
16
20
|
emptyText = 'No results found.',
|
|
17
21
|
testId,
|
|
18
22
|
itemIcon,
|
|
23
|
+
itemSnippet,
|
|
19
24
|
searchIcon,
|
|
25
|
+
enableHotkey = true,
|
|
26
|
+
filterFn = defaultFilter,
|
|
20
27
|
onselect,
|
|
21
28
|
onclose,
|
|
22
|
-
classes
|
|
29
|
+
classes,
|
|
30
|
+
ariaLabel = 'Command menu',
|
|
31
|
+
clearStateOnClose = false
|
|
23
32
|
}: CommandMenuProperties = $props();
|
|
24
33
|
|
|
25
34
|
// Stable per-instance ids keep ARIA references distinct across menus.
|
|
@@ -61,8 +70,7 @@
|
|
|
61
70
|
if (query.trim() === '') {
|
|
62
71
|
return items;
|
|
63
72
|
}
|
|
64
|
-
|
|
65
|
-
return items.filter((item) => item.label.toLowerCase().includes(lower));
|
|
73
|
+
return items.filter((item) => filterFn(item, query));
|
|
66
74
|
});
|
|
67
75
|
|
|
68
76
|
let groupedItems = $derived.by(() => {
|
|
@@ -231,7 +239,17 @@
|
|
|
231
239
|
}
|
|
232
240
|
}
|
|
233
241
|
|
|
242
|
+
// No focus or typing guard: this listener fires from anywhere, including inside a text
|
|
243
|
+
// field or a terminal. A consumer whose hotkey character can legitimately be typed
|
|
244
|
+
// (Ctrl+K in a shell is kill-line) sets enableHotkey={false} and drives `open` itself from
|
|
245
|
+
// a listener that knows what is focused. The listener stays the plain window listener it
|
|
246
|
+
// always was, added in onMount and removed in onDestroy, so its timing and its order
|
|
247
|
+
// against other listeners are unchanged; the opt-out is read per event, which also makes
|
|
248
|
+
// toggling enableHotkey after mount take effect immediately.
|
|
234
249
|
function handleGlobalKeyDown(event: KeyboardEvent) {
|
|
250
|
+
if (!enableHotkey) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
235
253
|
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
|
|
236
254
|
event.preventDefault();
|
|
237
255
|
if (open) {
|
|
@@ -293,6 +311,17 @@
|
|
|
293
311
|
destroy() {
|
|
294
312
|
releaseDismissible();
|
|
295
313
|
unlockBodyScroll();
|
|
314
|
+
// Opt-in only (clearStateOnClose): a controlled parent can set
|
|
315
|
+
// `open = false` without calling close(), and the overlay action still
|
|
316
|
+
// unmounts on every close path either way -- this is the lifecycle
|
|
317
|
+
// backstop that clears stale search state on reopen, for a consumer
|
|
318
|
+
// who asked for it. Off by default so an existing consumer's typed
|
|
319
|
+
// query/highlight keep surviving an externally-controlled close
|
|
320
|
+
// exactly as before this prop existed.
|
|
321
|
+
if (clearStateOnClose) {
|
|
322
|
+
query = '';
|
|
323
|
+
activeIndex = 0;
|
|
324
|
+
}
|
|
296
325
|
if (openerElement !== null) {
|
|
297
326
|
openerElement.focus();
|
|
298
327
|
openerElement = null;
|
|
@@ -337,7 +366,7 @@
|
|
|
337
366
|
onkeydown={handleKeyDown}
|
|
338
367
|
role="dialog"
|
|
339
368
|
aria-modal="true"
|
|
340
|
-
aria-label=
|
|
369
|
+
aria-label={ariaLabel}
|
|
341
370
|
tabindex="-1"
|
|
342
371
|
inert={!open}
|
|
343
372
|
data-pw={typeof testId === 'string' ? testId : null}
|
|
@@ -412,22 +441,26 @@
|
|
|
412
441
|
data-pw={typeof testId === 'string' ? `${testId}-item-${item.value}` : null}
|
|
413
442
|
testID={typeof testId === 'string' ? `${testId}-item-${item.value}` : null}
|
|
414
443
|
>
|
|
415
|
-
{#if typeof
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
444
|
+
{#if typeof itemSnippet === 'function'}
|
|
445
|
+
{@render itemSnippet(item, index === activeIndex)}
|
|
446
|
+
{:else}
|
|
447
|
+
{#if typeof itemIcon === 'function'}
|
|
448
|
+
<span class="command-menu-item-icon">
|
|
449
|
+
{@render itemIcon(item)}
|
|
450
|
+
</span>
|
|
451
|
+
{:else if typeof item.icon === 'string' && item.icon.length > 0}
|
|
452
|
+
<div class="command-menu-item-icon-img-wrapper">
|
|
453
|
+
<Img inlineSvg src={item.icon} alt="" />
|
|
454
|
+
</div>
|
|
455
|
+
{/if}
|
|
456
|
+
<span class="command-menu-item-label">{item.label}</span>
|
|
457
|
+
{#if typeof item.shortcut === 'string' && item.shortcut.length > 0}
|
|
458
|
+
<span class="command-menu-item-shortcut">
|
|
459
|
+
{#each item.shortcut.split('+') as key, i (i)}
|
|
460
|
+
<kbd class="command-menu-kbd">{key.trim()}</kbd>
|
|
461
|
+
{/each}
|
|
462
|
+
</span>
|
|
463
|
+
{/if}
|
|
431
464
|
{/if}
|
|
432
465
|
</button>
|
|
433
466
|
{/each}
|
|
@@ -17,8 +17,32 @@ export type OptionalCommandMenuProperties = {
|
|
|
17
17
|
emptyText?: string;
|
|
18
18
|
testId?: string;
|
|
19
19
|
itemIcon?: Snippet<[CommandItem]>;
|
|
20
|
+
/** Replaces the default icon/label/shortcut row entirely. Receives the item and whether it is the active (arrow-key-highlighted) row. */
|
|
21
|
+
itemSnippet?: Snippet<[CommandItem, boolean]>;
|
|
20
22
|
searchIcon?: Snippet;
|
|
23
|
+
/**
|
|
24
|
+
* Attaches the built-in `Cmd/Ctrl+K` window listener. Default `true`. This
|
|
25
|
+
* listener has no focus or typing guard — it fires from inside a text
|
|
26
|
+
* field or a terminal exactly as it does anywhere else. Set `false` when
|
|
27
|
+
* the hotkey character can legitimately be typed (Ctrl+K is kill-line in
|
|
28
|
+
* a shell) and drive `open` from the consumer's own guarded listener
|
|
29
|
+
* instead; the bindable `open` prop is then the sole control.
|
|
30
|
+
*/
|
|
31
|
+
enableHotkey?: boolean;
|
|
32
|
+
/** Mirrors Combobox's filterFn. Default matches `item.label` case-insensitively; a consumer with more than a label to search on (a hint, a path) supplies its own. */
|
|
33
|
+
filterFn?: (item: CommandItem, query: string) => boolean;
|
|
21
34
|
classes?: string;
|
|
35
|
+
/** Accessible name of the dialog. Default `'Command menu'`. */
|
|
36
|
+
ariaLabel?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Default `false`. A controlled parent can set `open = false` without
|
|
39
|
+
* calling the component's own `close()` -- the overlay still unmounts on
|
|
40
|
+
* every close path either way, so `true` clears the typed search query and
|
|
41
|
+
* highlighted index on that unmount too, not only on an internal close.
|
|
42
|
+
* Left off by default so an existing consumer's typed state keeps
|
|
43
|
+
* surviving an externally-controlled close exactly as before.
|
|
44
|
+
*/
|
|
45
|
+
clearStateOnClose?: boolean;
|
|
22
46
|
};
|
|
23
47
|
export type CommandMenuEventProperties = {
|
|
24
48
|
onselect?: (item: CommandItem) => void;
|