@juspay/svelte-ui-components 4.34.1 → 4.35.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.
@@ -20,6 +20,7 @@
20
20
 
21
21
  let {
22
22
  value = $bindable(''),
23
+ layout = 'row',
23
24
  placeholder = '',
24
25
  disabled = false,
25
26
  textDisabled = null,
@@ -57,6 +58,7 @@
57
58
  attachIcon,
58
59
  actionIcon,
59
60
  actionLabel = 'Voice conversation',
61
+ actionText,
60
62
  leading,
61
63
  statusText,
62
64
  statusTestId,
@@ -346,6 +348,140 @@
346
348
  };
347
349
  </script>
348
350
 
351
+ {#snippet leadingControls()}
352
+ {#if showAttach}
353
+ <div class="control attach">
354
+ <Button
355
+ variant="ghost"
356
+ onclick={() => {
357
+ if (typeof onattachclick === 'function') {
358
+ onattachclick();
359
+ return;
360
+ }
361
+ fileInput?.click();
362
+ }}
363
+ {disabled}
364
+ ariaLabel={attachLabel}
365
+ testId={attachTestId}
366
+ >
367
+ {#if typeof attachIcon === 'function'}
368
+ {@render attachIcon()}
369
+ {:else}
370
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
371
+ {@html attachSvg}
372
+ {/if}
373
+ </Button>
374
+ <input
375
+ bind:this={fileInput}
376
+ type="file"
377
+ {accept}
378
+ {multiple}
379
+ class="file-input"
380
+ onchange={handleFiles}
381
+ hidden
382
+ />
383
+ </div>
384
+ {/if}
385
+
386
+ {#if typeof leading === 'function'}
387
+ <div class="leading">{@render leading()}</div>
388
+ {/if}
389
+ {/snippet}
390
+
391
+ {#snippet textInput()}
392
+ <textarea
393
+ class="input"
394
+ data-pw={inputTestId ?? null}
395
+ bind:value
396
+ {placeholder}
397
+ disabled={resolvedTextDisabled}
398
+ rows="1"
399
+ aria-label={inputAriaLabel ?? (placeholder.length > 0 ? placeholder : 'Message')}
400
+ role={slashCommands !== null ? 'combobox' : null}
401
+ aria-expanded={slashCommands !== null ? slashOpen : null}
402
+ aria-controls={slashOpen ? slashListboxId : null}
403
+ aria-autocomplete={slashCommands !== null ? 'list' : null}
404
+ aria-activedescendant={slashActiveId}
405
+ maxlength={maxLength > 0 ? maxLength : null}
406
+ oninput={handleInput}
407
+ onkeydown={handleKeydown}
408
+ onpaste={(event) => onpaste?.(event)}
409
+ use:autoGrow={value}
410
+ ></textarea>
411
+ {/snippet}
412
+
413
+ {#snippet trailingControls()}
414
+ {#if showVoice}
415
+ <div
416
+ class="control voice"
417
+ class:recording={dictationState === 'recording'}
418
+ class:busy={dictationState === 'busy'}
419
+ >
420
+ <Button
421
+ variant="ghost"
422
+ onclick={() => onvoice?.()}
423
+ disabled={resolvedVoiceDisabled}
424
+ ariaLabel={voiceLabel}
425
+ testId={voiceTestId}
426
+ >
427
+ {#if typeof voiceIcon === 'function'}
428
+ {@render voiceIcon()}
429
+ {:else}
430
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
431
+ {@html micSvg}
432
+ {/if}
433
+ </Button>
434
+ </div>
435
+ {/if}
436
+
437
+ {#if streaming}
438
+ <div class="control send stop" data-pw={sendSlotTestId ?? null}>
439
+ <Button onclick={() => onstop?.()} ariaLabel={stopLabel} testId={stopTestId}>
440
+ {#if typeof stopIcon === 'function'}
441
+ {@render stopIcon()}
442
+ {:else}
443
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
444
+ {@html stopSvg}
445
+ {/if}
446
+ </Button>
447
+ </div>
448
+ {:else if typeof onaction === 'function' && !canSend && dictationState === 'idle'}
449
+ <div
450
+ class="control send action"
451
+ class:action-with-text={typeof actionText === 'string' && actionText.length > 0}
452
+ data-pw={sendSlotTestId ?? null}
453
+ >
454
+ <Button
455
+ onclick={() => onaction()}
456
+ disabled={resolvedSendDisabled}
457
+ ariaLabel={actionLabel}
458
+ testId={actionTestId}
459
+ >
460
+ {#if typeof actionIcon === 'function'}
461
+ {@render actionIcon()}
462
+ {:else}
463
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
464
+ {@html micSvg}
465
+ {/if}
466
+ {#if typeof actionText === 'string' && actionText.length > 0}
467
+ <span>{actionText}</span>
468
+ {/if}
469
+ </Button>
470
+ </div>
471
+ {:else}
472
+ <div class="control send" data-pw={sendSlotTestId ?? null}>
473
+ <Button onclick={submit} disabled={!canSend} ariaLabel={sendLabel} testId={sendTestId}>
474
+ {#if typeof sendIcon === 'function'}
475
+ {@render sendIcon()}
476
+ {:else}
477
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
478
+ {@html sendSvg}
479
+ {/if}
480
+ </Button>
481
+ </div>
482
+ {/if}
483
+ {/snippet}
484
+
349
485
  <div
350
486
  class="chat-composer {classes ?? ''}"
351
487
  class:disabled
@@ -441,126 +577,18 @@
441
577
  </div>
442
578
  {/if}
443
579
 
444
- <div class="input-row">
445
- {#if showAttach}
446
- <div class="control attach">
447
- <Button
448
- variant="ghost"
449
- onclick={() => {
450
- if (typeof onattachclick === 'function') {
451
- onattachclick();
452
- return;
453
- }
454
- fileInput?.click();
455
- }}
456
- {disabled}
457
- ariaLabel={attachLabel}
458
- testId={attachTestId}
459
- >
460
- {#if typeof attachIcon === 'function'}
461
- {@render attachIcon()}
462
- {:else}
463
- <!-- eslint-disable-next-line svelte/no-at-html-tags -->
464
- {@html attachSvg}
465
- {/if}
466
- </Button>
467
- <input
468
- bind:this={fileInput}
469
- type="file"
470
- {accept}
471
- {multiple}
472
- class="file-input"
473
- onchange={handleFiles}
474
- hidden
475
- />
476
- </div>
477
- {/if}
478
-
479
- {#if typeof leading === 'function'}
480
- <div class="leading">{@render leading()}</div>
481
- {/if}
482
-
483
- <textarea
484
- class="input"
485
- data-pw={inputTestId ?? null}
486
- bind:value
487
- {placeholder}
488
- disabled={resolvedTextDisabled}
489
- rows="1"
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}
496
- maxlength={maxLength > 0 ? maxLength : null}
497
- oninput={handleInput}
498
- onkeydown={handleKeydown}
499
- onpaste={(event) => onpaste?.(event)}
500
- use:autoGrow={value}
501
- ></textarea>
502
-
503
- {#if showVoice}
504
- <div
505
- class="control voice"
506
- class:recording={dictationState === 'recording'}
507
- class:busy={dictationState === 'busy'}
508
- >
509
- <Button
510
- variant="ghost"
511
- onclick={() => onvoice?.()}
512
- disabled={resolvedVoiceDisabled}
513
- ariaLabel={voiceLabel}
514
- testId={voiceTestId}
515
- >
516
- {#if typeof voiceIcon === 'function'}
517
- {@render voiceIcon()}
518
- {:else}
519
- <!-- eslint-disable-next-line svelte/no-at-html-tags -->
520
- {@html micSvg}
521
- {/if}
522
- </Button>
523
- </div>
524
- {/if}
525
-
526
- {#if streaming}
527
- <div class="control send stop" data-pw={sendSlotTestId ?? null}>
528
- <Button onclick={() => onstop?.()} ariaLabel={stopLabel} testId={stopTestId}>
529
- {#if typeof stopIcon === 'function'}
530
- {@render stopIcon()}
531
- {:else}
532
- <!-- eslint-disable-next-line svelte/no-at-html-tags -->
533
- {@html stopSvg}
534
- {/if}
535
- </Button>
536
- </div>
537
- {:else if typeof onaction === 'function' && !canSend && dictationState === 'idle'}
538
- <div class="control send action" data-pw={sendSlotTestId ?? null}>
539
- <Button
540
- onclick={() => onaction()}
541
- disabled={resolvedSendDisabled}
542
- ariaLabel={actionLabel}
543
- testId={actionTestId}
544
- >
545
- {#if typeof actionIcon === 'function'}
546
- {@render actionIcon()}
547
- {:else}
548
- <!-- eslint-disable-next-line svelte/no-at-html-tags -->
549
- {@html micSvg}
550
- {/if}
551
- </Button>
580
+ <div class="input-row" class:stacked={layout === 'stacked'}>
581
+ {#if layout === 'stacked'}
582
+ <div class="text-row">{@render textInput()}</div>
583
+ <div class="control-row">
584
+ {@render leadingControls()}
585
+ <div class="control-spacer" aria-hidden="true"></div>
586
+ {@render trailingControls()}
552
587
  </div>
553
588
  {:else}
554
- <div class="control send" data-pw={sendSlotTestId ?? null}>
555
- <Button onclick={submit} disabled={!canSend} ariaLabel={sendLabel} testId={sendTestId}>
556
- {#if typeof sendIcon === 'function'}
557
- {@render sendIcon()}
558
- {:else}
559
- <!-- eslint-disable-next-line svelte/no-at-html-tags -->
560
- {@html sendSvg}
561
- {/if}
562
- </Button>
563
- </div>
589
+ {@render leadingControls()}
590
+ {@render textInput()}
591
+ {@render trailingControls()}
564
592
  {/if}
565
593
  </div>
566
594
  </div>
@@ -609,6 +637,32 @@
609
637
  gap: var(--chat-composer-gap, 8px);
610
638
  }
611
639
 
640
+ .input-row.stacked {
641
+ flex-direction: column;
642
+ align-items: stretch;
643
+ gap: var(--chat-composer-stack-gap, 8px);
644
+ }
645
+
646
+ .text-row {
647
+ display: flex;
648
+ min-width: 0;
649
+ }
650
+
651
+ .control-row {
652
+ display: flex;
653
+ align-items: flex-end;
654
+ gap: var(--chat-composer-gap, 8px);
655
+ }
656
+
657
+ .control-spacer {
658
+ flex: 1;
659
+ }
660
+
661
+ .stacked .input {
662
+ min-width: 0;
663
+ width: 100%;
664
+ }
665
+
612
666
  .leading {
613
667
  display: flex;
614
668
  align-items: center;
@@ -776,6 +830,46 @@
776
830
  color: var(--chat-composer-slash-badge-color, #2563eb);
777
831
  }
778
832
 
833
+ .send.action {
834
+ --button-width: var(--chat-composer-idle-action-width, var(--chat-composer-send-size, 40px));
835
+ --button-padding: var(
836
+ --chat-composer-idle-action-padding,
837
+ var(--chat-composer-send-padding, 8px)
838
+ );
839
+ --button-border-radius: var(
840
+ --chat-composer-idle-action-border-radius,
841
+ var(--chat-composer-send-border-radius, 50%)
842
+ );
843
+ --button-color: var(
844
+ --chat-composer-idle-action-background-color,
845
+ var(--chat-composer-send-background-color, #18181b)
846
+ );
847
+ --button-text-color: var(
848
+ --chat-composer-idle-action-color,
849
+ var(--chat-composer-send-color, #ffffff)
850
+ );
851
+ --button-hover-color: var(
852
+ --chat-composer-idle-action-hover-background-color,
853
+ var(--chat-composer-send-hover-background-color, #27272a)
854
+ );
855
+ --button-hover-text-color: var(
856
+ --chat-composer-idle-action-hover-color,
857
+ var(--button-text-color)
858
+ );
859
+ --button-content-gap: var(--chat-composer-idle-action-gap, 0px);
860
+ }
861
+
862
+ .send.action.action-with-text {
863
+ --button-width: var(--chat-composer-idle-action-width, max-content);
864
+ --button-padding: var(--chat-composer-idle-action-padding, 8px 12px);
865
+ --button-content-gap: var(--chat-composer-idle-action-gap, 6px);
866
+ }
867
+
868
+ .action-with-text :global(svg) {
869
+ width: var(--chat-composer-idle-action-icon-size, 20px);
870
+ height: var(--chat-composer-idle-action-icon-size, 20px);
871
+ }
872
+
779
873
  /* Visually hidden but still read by assistive technology -- same pattern
780
874
  Table's caption and Label's required marker use. */
781
875
  .sr-only {
@@ -3,6 +3,8 @@ import type { AttachmentChipFile, AttachmentChipImage, AttachmentChipVideo } fro
3
3
  export type ChatComposerProperties = OptionalChatComposerProperties & ChatComposerEventProperties;
4
4
  export type OptionalChatComposerProperties = {
5
5
  value?: string;
6
+ /** Text above a separate control row when stacked; row preserves the default. */
7
+ layout?: 'row' | 'stacked';
6
8
  placeholder?: string;
7
9
  disabled?: boolean;
8
10
  /**
@@ -80,6 +82,8 @@ export type OptionalChatComposerProperties = {
80
82
  /** Glyph for the idle action button (see `onaction`). */
81
83
  actionIcon?: Snippet;
82
84
  actionLabel?: string;
85
+ /** Optional visible text beside the idle action icon; omitted keeps icon-only markup. */
86
+ actionText?: string;
83
87
  leading?: Snippet;
84
88
  /**
85
89
  * Opt-in sr-only status region (`role="status" aria-live="polite"`) for
@@ -16,6 +16,8 @@
16
16
  let {
17
17
  items,
18
18
  open = $bindable(false),
19
+ query = $bindable(''),
20
+ shortcutEnabled = true,
19
21
  placeholder = 'Search commands...',
20
22
  emptyText = 'No results found.',
21
23
  testId,
@@ -28,7 +30,8 @@
28
30
  onclose,
29
31
  classes,
30
32
  ariaLabel = 'Command menu',
31
- clearStateOnClose = false
33
+ clearStateOnClose = false,
34
+ onquerychange
32
35
  }: CommandMenuProperties = $props();
33
36
 
34
37
  // Stable per-instance ids keep ARIA references distinct across menus.
@@ -56,8 +59,6 @@
56
59
  easingTokens: ['--command-menu-panel-transition-easing', '--ease-smooth-out', '--motion-easing']
57
60
  };
58
61
 
59
- let query = $state('');
60
- let activeIndex = $state(0);
61
62
  let inputElement: HTMLInputElement | null = $state(null);
62
63
  let listElement: HTMLDivElement | null = $state(null);
63
64
  let dialogElement: HTMLDivElement | null = $state(null);
@@ -98,6 +99,10 @@
98
99
  return result;
99
100
  });
100
101
 
102
+ // A changed result list (including externally changed query/items) resets
103
+ // navigation. Keyboard/pointer navigation can override this until it changes.
104
+ let activeIndex = $derived(flatItems.length > 0 ? 0 : -1);
105
+
101
106
  let flatIndexLookup = $derived.by(() => {
102
107
  const map = new SvelteMap<string, number>();
103
108
  for (let i = 0; i < flatItems.length; i++) {
@@ -113,7 +118,7 @@
113
118
  // is keyed by its flat index (see the option's own id below) — null once the
114
119
  // list is empty, since there is then no option to describe.
115
120
  let activeDescendantId = $derived(
116
- flatItems.length > 0 ? `command-menu-option-${uid}-${activeIndex}` : null
121
+ flatItems.at(activeIndex) ? `command-menu-option-${uid}-${activeIndex}` : null
117
122
  );
118
123
 
119
124
  // Guarded rather than assumed single-fire: out:tokenizedFly keeps the dialog
@@ -125,7 +130,10 @@
125
130
  return;
126
131
  }
127
132
  open = false;
128
- query = '';
133
+ if (query !== '') {
134
+ query = '';
135
+ onquerychange?.('');
136
+ }
129
137
  activeIndex = 0;
130
138
  onclose?.();
131
139
  }
@@ -151,7 +159,7 @@
151
159
  }
152
160
 
153
161
  function handleKeyDown(event: KeyboardEvent) {
154
- if (!open) {
162
+ if (!open || event.isComposing || event.keyCode === 229) {
155
163
  return;
156
164
  }
157
165
 
@@ -247,10 +255,31 @@
247
255
  // against other listeners are unchanged; the opt-out is read per event, which also makes
248
256
  // toggling enableHotkey after mount take effect immediately.
249
257
  function handleGlobalKeyDown(event: KeyboardEvent) {
250
- if (!enableHotkey) {
258
+ if (
259
+ !shortcutEnabled ||
260
+ !enableHotkey ||
261
+ event.defaultPrevented ||
262
+ event.isComposing ||
263
+ event.keyCode === 229 ||
264
+ event.repeat ||
265
+ event.altKey
266
+ ) {
251
267
  return;
252
268
  }
253
- if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
269
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
270
+ const dialog = event
271
+ .composedPath()
272
+ .find(
273
+ (node) =>
274
+ node instanceof Element &&
275
+ node.matches('[role="dialog"], [role="alertdialog"], [aria-modal="true"]')
276
+ );
277
+ if (
278
+ dialog instanceof Element &&
279
+ (dialogElement === null || !dialog.contains(dialogElement))
280
+ ) {
281
+ return;
282
+ }
254
283
  event.preventDefault();
255
284
  if (open) {
256
285
  close();
@@ -388,9 +417,11 @@
388
417
  {/if}
389
418
  <input
390
419
  bind:this={inputElement}
391
- bind:value={query}
392
- oninput={() => {
420
+ value={query}
421
+ oninput={(event) => {
422
+ query = event.currentTarget.value;
393
423
  activeIndex = 0;
424
+ onquerychange?.(query);
394
425
  }}
395
426
  type="text"
396
427
  class="command-menu-input"
@@ -1,4 +1,4 @@
1
1
  import type { CommandMenuProperties } from './properties';
2
- declare const CommandMenu: import("svelte").Component<CommandMenuProperties, {}, "open">;
2
+ declare const CommandMenu: import("svelte").Component<CommandMenuProperties, {}, "open" | "query">;
3
3
  type CommandMenu = ReturnType<typeof CommandMenu>;
4
4
  export default CommandMenu;
@@ -13,6 +13,10 @@ export type MandatoryCommandMenuProperties = {
13
13
  };
14
14
  export type OptionalCommandMenuProperties = {
15
15
  open?: boolean;
16
+ /** Bindable search text. Omit to keep the built-in uncontrolled search. */
17
+ query?: string;
18
+ /** Disable the built-in global Cmd/Ctrl+K listener when the caller owns shortcuts. */
19
+ shortcutEnabled?: boolean;
16
20
  placeholder?: string;
17
21
  emptyText?: string;
18
22
  testId?: string;
@@ -45,6 +49,8 @@ export type OptionalCommandMenuProperties = {
45
49
  clearStateOnClose?: boolean;
46
50
  };
47
51
  export type CommandMenuEventProperties = {
52
+ /** Fires on user input and when closing resets a non-empty query. */
53
+ onquerychange?: (query: string) => void;
48
54
  onselect?: (item: CommandItem) => void;
49
55
  onclose?: () => void;
50
56
  };