@juspay/svelte-ui-components 4.33.3 → 4.34.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.
Files changed (46) hide show
  1. package/dist/Accordion/Accordion.svelte +26 -2
  2. package/dist/Accordion/properties.d.ts +7 -0
  3. package/dist/ArmedButton/ArmedButton.svelte +147 -0
  4. package/dist/ArmedButton/ArmedButton.svelte.d.ts +4 -0
  5. package/dist/ArmedButton/properties.d.ts +24 -0
  6. package/dist/ArmedButton/properties.js +1 -0
  7. package/dist/Banner/Banner.svelte +13 -8
  8. package/dist/Banner/properties.d.ts +8 -1
  9. package/dist/Button/Button.svelte +26 -0
  10. package/dist/Button/properties.d.ts +17 -0
  11. package/dist/ChatComposer/ChatComposer.svelte +314 -1
  12. package/dist/ChatComposer/properties.d.ts +42 -0
  13. package/dist/ChatMessageList/ChatMessageList.svelte +22 -2
  14. package/dist/ChatMessageList/ChatMessageList.svelte.d.ts +1 -1
  15. package/dist/CommandMenu/CommandMenu.svelte +53 -20
  16. package/dist/CommandMenu/properties.d.ts +24 -0
  17. package/dist/DiffViewer/DiffViewer.svelte +210 -0
  18. package/dist/DiffViewer/DiffViewer.svelte.d.ts +4 -0
  19. package/dist/DiffViewer/properties.d.ts +25 -0
  20. package/dist/DiffViewer/properties.js +1 -0
  21. package/dist/EmptyState/EmptyState.svelte +22 -3
  22. package/dist/EmptyState/properties.d.ts +9 -0
  23. package/dist/HITL/HITL.svelte +318 -49
  24. package/dist/HITL/properties.d.ts +113 -11
  25. package/dist/Input/Input.svelte +45 -1
  26. package/dist/Input/properties.d.ts +15 -0
  27. package/dist/KeyValue/KeyValue.svelte +16 -3
  28. package/dist/KeyValue/properties.d.ts +2 -0
  29. package/dist/Progress/Progress.svelte +96 -41
  30. package/dist/Progress/properties.d.ts +19 -0
  31. package/dist/Snippet/Snippet.svelte +45 -1
  32. package/dist/Snippet/properties.d.ts +8 -0
  33. package/dist/Tabs/Tabs.svelte +75 -16
  34. package/dist/Tabs/properties.d.ts +21 -0
  35. package/dist/ThinkingIndicator/ThinkingIndicator.svelte.d.ts +1 -1
  36. package/dist/Toolbar/Toolbar.svelte +81 -3
  37. package/dist/Toolbar/properties.d.ts +37 -0
  38. package/dist/WindowedList/WindowedList.svelte +129 -0
  39. package/dist/WindowedList/WindowedList.svelte.d.ts +25 -0
  40. package/dist/WindowedList/properties.d.ts +38 -0
  41. package/dist/WindowedList/properties.js +1 -0
  42. package/dist/index.d.ts +6 -0
  43. package/dist/index.js +3 -0
  44. package/dist/styles/theme-dark.css +42 -0
  45. package/dist-wc/index.js +2637 -1642
  46. 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 type { ChatComposerProperties } from './properties';
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
- export function scrollToBottom(): void {
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
- const lower = query.toLowerCase();
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="Command menu"
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 itemIcon === 'function'}
416
- <span class="command-menu-item-icon">
417
- {@render itemIcon(item)}
418
- </span>
419
- {:else if typeof item.icon === 'string' && item.icon.length > 0}
420
- <div class="command-menu-item-icon-img-wrapper">
421
- <Img inlineSvg src={item.icon} alt="" />
422
- </div>
423
- {/if}
424
- <span class="command-menu-item-label">{item.label}</span>
425
- {#if typeof item.shortcut === 'string' && item.shortcut.length > 0}
426
- <span class="command-menu-item-shortcut">
427
- {#each item.shortcut.split('+') as key, i (i)}
428
- <kbd class="command-menu-kbd">{key.trim()}</kbd>
429
- {/each}
430
- </span>
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;