@juspay/svelte-ui-components 4.15.0 → 4.17.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.
@@ -14,6 +14,10 @@
14
14
  searchable = false,
15
15
  placeholder = '',
16
16
  disabled = false,
17
+ error = false,
18
+ errorMessage,
19
+ clearable = false,
20
+ onclear,
17
21
  bottomContent,
18
22
  optionIndicator,
19
23
  showSelectAll = false,
@@ -40,6 +44,7 @@
40
44
 
41
45
  let items: SelectItem[] = $derived(normalizeItems(rawItems));
42
46
  let query = $state('');
47
+ let restoringFocus = false;
43
48
  let highlightedIndex = $state(-1);
44
49
  let containerEl: HTMLDivElement | null = $state(null);
45
50
  let searchInputEl: HTMLInputElement | null = $state(null);
@@ -55,7 +60,38 @@
55
60
  // default. The in-flow panel still honours the CSS var via its margin-top.
56
61
  const PORTAL_DROPDOWN_GAP = 4;
57
62
 
58
- const listboxId = `select-listbox-${Math.random().toString(36).slice(2, 9)}`;
63
+ const instanceId = $props.id();
64
+ const listboxId = `select-listbox-${instanceId}`;
65
+ const errorMessageId = `${listboxId}-error`;
66
+
67
+ const hasErrorMessage = $derived(
68
+ error && typeof errorMessage === 'string' && errorMessage.trim().length > 0
69
+ );
70
+
71
+ /* Nothing selected means nothing to clear, so the control is not rendered at
72
+ all rather than rendered inert -- a disabled × that never enables reads as
73
+ broken. Same for `disabled`, where the whole field is inert already. */
74
+ const showClearButton = $derived(clearable && !disabled && value.length > 0);
75
+
76
+ function handleClear(event: MouseEvent): void {
77
+ /* The point of a trigger-level clear is that it does NOT open the menu.
78
+ The trigger's own click handler sits on the ancestor, so without this
79
+ the click would clear and then immediately open. */
80
+ event.stopPropagation();
81
+ if (value.length === 0) {
82
+ return;
83
+ }
84
+ value = [];
85
+ onchange?.(value);
86
+ onclear?.();
87
+ if (searchable && searchInputEl !== null) {
88
+ restoringFocus = true;
89
+ searchInputEl.focus();
90
+ restoringFocus = false;
91
+ } else {
92
+ triggerEl?.focus();
93
+ }
94
+ }
59
95
 
60
96
  function getLabel(id: string): string {
61
97
  const found = items.find((item) => item.id === id);
@@ -354,7 +390,7 @@
354
390
  }
355
391
 
356
392
  function handleSearchFocus(): void {
357
- if (!open) {
393
+ if (!open && !restoringFocus) {
358
394
  openDropdown();
359
395
  }
360
396
  }
@@ -388,104 +424,144 @@
388
424
  class="select {classes ?? ''}"
389
425
  class:open
390
426
  class:disabled
427
+ class:error
391
428
  class:ghost={hierarchy === 'ghost'}
392
429
  bind:this={containerEl}
393
430
  {...typeof testId === 'string' ? { 'data-pw': testId, testID: testId } : {}}
394
431
  >
395
- <div
396
- class="select-trigger"
397
- bind:this={triggerEl}
398
- onclick={handleTriggerClick}
399
- onkeydown={handleKeydown}
400
- role="combobox"
401
- aria-expanded={open}
402
- aria-haspopup="listbox"
403
- aria-controls={listboxId}
404
- {...highlightedOptionId !== null ? { 'aria-activedescendant': highlightedOptionId } : {}}
405
- tabindex={disabled ? -1 : searchable ? -1 : 0}
406
- >
407
- {#if typeof leftIcon === 'string' && leftIcon.length > 0}
408
- <Img
409
- inlineSvg
410
- src={leftIcon}
411
- alt=""
412
- fallback=""
413
- classes="select-left-icon"
414
- {...typeof leftIconTestId === 'string' ? { testId: leftIconTestId } : {}}
415
- />
416
- {/if}
417
- {#if multiple}
418
- {#if typeof triggerSummary === 'function'}
419
- {@render triggerSummary({ value, items })}
420
- {#if searchable}
421
- <input
422
- class="select-search"
423
- type="text"
424
- value={query}
425
- oninput={handleSearchInput}
426
- onfocus={handleSearchFocus}
427
- bind:this={searchInputEl}
428
- placeholder={value.length === 0 ? placeholder : ''}
429
- {disabled}
430
- autocomplete="off"
431
- tabindex={disabled ? -1 : 0}
432
- data-pw={typeof testId === 'string' ? `${testId}-search` : null}
433
- testID={typeof testId === 'string' ? `${testId}-search` : null}
434
- />
432
+ <div class="select-control" class:has-clear={showClearButton}>
433
+ <div
434
+ class="select-trigger"
435
+ bind:this={triggerEl}
436
+ onclick={handleTriggerClick}
437
+ onkeydown={handleKeydown}
438
+ role="combobox"
439
+ aria-expanded={open}
440
+ aria-haspopup="listbox"
441
+ aria-controls={listboxId}
442
+ aria-invalid={error ? 'true' : null}
443
+ aria-describedby={hasErrorMessage ? errorMessageId : null}
444
+ {...highlightedOptionId !== null ? { 'aria-activedescendant': highlightedOptionId } : {}}
445
+ tabindex={disabled ? -1 : searchable ? -1 : 0}
446
+ >
447
+ {#if typeof leftIcon === 'string' && leftIcon.length > 0}
448
+ <Img
449
+ inlineSvg
450
+ src={leftIcon}
451
+ alt=""
452
+ fallback=""
453
+ classes="select-left-icon"
454
+ {...typeof leftIconTestId === 'string' ? { testId: leftIconTestId } : {}}
455
+ />
456
+ {/if}
457
+ {#if multiple}
458
+ {#if typeof triggerSummary === 'function'}
459
+ {@render triggerSummary({ value, items })}
460
+ {#if searchable}
461
+ <input
462
+ class="select-search"
463
+ type="text"
464
+ value={query}
465
+ oninput={handleSearchInput}
466
+ onfocus={handleSearchFocus}
467
+ bind:this={searchInputEl}
468
+ placeholder={value.length === 0 ? placeholder : ''}
469
+ {disabled}
470
+ aria-invalid={error ? 'true' : null}
471
+ aria-describedby={hasErrorMessage ? errorMessageId : null}
472
+ autocomplete="off"
473
+ tabindex={disabled ? -1 : 0}
474
+ data-pw={typeof testId === 'string' ? `${testId}-search` : null}
475
+ testID={typeof testId === 'string' ? `${testId}-search` : null}
476
+ />
477
+ {/if}
478
+ {:else}
479
+ {#each value as id (id)}
480
+ <Pill
481
+ text={getLabel(id)}
482
+ dismissible
483
+ {disabled}
484
+ ondismiss={() => removeItem(id)}
485
+ {...typeof testId === 'string' ? { testId: `${testId}-pill-${id}` } : {}}
486
+ />
487
+ {/each}
488
+ {#if searchable}
489
+ <input
490
+ class="select-search"
491
+ type="text"
492
+ value={query}
493
+ oninput={handleSearchInput}
494
+ onfocus={handleSearchFocus}
495
+ bind:this={searchInputEl}
496
+ placeholder={value.length === 0 ? placeholder : ''}
497
+ {disabled}
498
+ aria-invalid={error ? 'true' : null}
499
+ aria-describedby={hasErrorMessage ? errorMessageId : null}
500
+ autocomplete="off"
501
+ tabindex={disabled ? -1 : 0}
502
+ data-pw={typeof testId === 'string' ? `${testId}-search` : null}
503
+ testID={typeof testId === 'string' ? `${testId}-search` : null}
504
+ />
505
+ {:else if value.length === 0}
506
+ <span class="select-placeholder">{placeholder}</span>
507
+ {/if}
435
508
  {/if}
509
+ {:else if searchable}
510
+ <input
511
+ class="select-search"
512
+ type="text"
513
+ value={open ? query : displayText}
514
+ oninput={handleSearchInput}
515
+ onfocus={handleSearchFocus}
516
+ bind:this={searchInputEl}
517
+ placeholder={searchPlaceholder}
518
+ {disabled}
519
+ aria-invalid={error ? 'true' : null}
520
+ aria-describedby={hasErrorMessage ? errorMessageId : null}
521
+ autocomplete="off"
522
+ tabindex={disabled ? -1 : 0}
523
+ data-pw={typeof testId === 'string' ? `${testId}-search` : null}
524
+ testID={typeof testId === 'string' ? `${testId}-search` : null}
525
+ />
436
526
  {:else}
437
- {#each value as id (id)}
438
- <Pill
439
- text={getLabel(id)}
440
- dismissible
441
- {disabled}
442
- ondismiss={() => removeItem(id)}
443
- {...typeof testId === 'string' ? { testId: `${testId}-pill-${id}` } : {}}
444
- />
445
- {/each}
446
- {#if searchable}
447
- <input
448
- class="select-search"
449
- type="text"
450
- value={query}
451
- oninput={handleSearchInput}
452
- onfocus={handleSearchFocus}
453
- bind:this={searchInputEl}
454
- placeholder={value.length === 0 ? placeholder : ''}
455
- {disabled}
456
- autocomplete="off"
457
- tabindex={disabled ? -1 : 0}
458
- data-pw={typeof testId === 'string' ? `${testId}-search` : null}
459
- testID={typeof testId === 'string' ? `${testId}-search` : null}
460
- />
461
- {:else if value.length === 0}
462
- <span class="select-placeholder">{placeholder}</span>
463
- {/if}
527
+ <span class={displayText.length > 0 ? 'select-value' : 'select-placeholder'}>
528
+ {displayText.length > 0 ? displayText : placeholder}
529
+ </span>
464
530
  {/if}
465
- {:else if searchable}
466
- <input
467
- class="select-search"
468
- type="text"
469
- value={open ? query : displayText}
470
- oninput={handleSearchInput}
471
- onfocus={handleSearchFocus}
472
- bind:this={searchInputEl}
473
- placeholder={searchPlaceholder}
474
- {disabled}
475
- autocomplete="off"
476
- tabindex={disabled ? -1 : 0}
477
- data-pw={typeof testId === 'string' ? `${testId}-search` : null}
478
- testID={typeof testId === 'string' ? `${testId}-search` : null}
479
- />
480
- {:else}
481
- <span class={displayText.length > 0 ? 'select-value' : 'select-placeholder'}>
482
- {displayText.length > 0 ? displayText : placeholder}
483
- </span>
531
+ <!-- eslint-disable svelte/no-at-html-tags -->
532
+ <span class="select-arrow">{@html chevronDownSvg}</span>
533
+ </div>
534
+ {#if showClearButton}
535
+ <button
536
+ type="button"
537
+ class="select-clear"
538
+ aria-label="Clear selection"
539
+ onkeydown={(event) => {
540
+ if (event.key === 'Enter' || event.key === ' ') {
541
+ event.stopPropagation();
542
+ }
543
+ }}
544
+ onclick={handleClear}
545
+ data-pw={typeof testId === 'string' ? `${testId}-clear` : null}
546
+ testID={typeof testId === 'string' ? `${testId}-clear` : null}
547
+ >
548
+ <span aria-hidden="true">×</span>
549
+ </button>
484
550
  {/if}
485
- <!-- eslint-disable svelte/no-at-html-tags -->
486
- <span class="select-arrow">{@html chevronDownSvg}</span>
487
551
  </div>
488
552
 
553
+ {#if hasErrorMessage}
554
+ <div
555
+ id={errorMessageId}
556
+ role="alert"
557
+ class="select-error-message"
558
+ data-pw={typeof testId === 'string' ? `${testId}-error-message` : null}
559
+ testID={typeof testId === 'string' ? `${testId}-error-message` : null}
560
+ >
561
+ {errorMessage}
562
+ </div>
563
+ {/if}
564
+
489
565
  {#if open && !disabled}
490
566
  <div
491
567
  class="select-dropdown"
@@ -684,6 +760,84 @@
684
760
  box-shadow: var(--select-trigger-focus-shadow, 0 0 0 2px rgba(37, 99, 235, 0.2));
685
761
  }
686
762
 
763
+ /* Pressed feedback on the trigger. `:active` only, so it lasts exactly as
764
+ long as the pointer is down and never lingers as a stuck state the way a
765
+ class-driven one can. */
766
+ .select:not(.disabled) .select-trigger:active {
767
+ background: var(--select-trigger-pressed-background, #ededed);
768
+ }
769
+
770
+ /* The error border has to outrank BOTH hover and focus/open, or the field
771
+ stops reading as invalid the moment the user enters it -- which is the one
772
+ moment they most need to see it. Listed after them, at equal or higher
773
+ specificity, rather than reached for with !important. */
774
+ .select.error .select-trigger,
775
+ .select.error .select-trigger:hover,
776
+ .select.error .select-trigger:focus-within,
777
+ .select.error.open .select-trigger {
778
+ border-color: var(--select-trigger-error-border-color, #dc2626);
779
+ }
780
+
781
+ .select.error .select-trigger:focus-within,
782
+ .select.error.open .select-trigger {
783
+ box-shadow: var(--select-trigger-error-shadow, 0 0 0 2px rgba(220, 38, 38, 0.2));
784
+ }
785
+
786
+ .select-error-message {
787
+ margin-top: var(--select-error-message-margin-top, 4px);
788
+ font-size: var(--select-error-message-font-size, 12px);
789
+ color: var(--select-error-message-color, #dc2626);
790
+ }
791
+
792
+ .select-control {
793
+ position: relative;
794
+ }
795
+
796
+ .select-control.has-clear .select-trigger {
797
+ padding-inline-end: calc(var(--select-clear-size, 18px) + 36px);
798
+ }
799
+
800
+ .select-control.has-clear .select-arrow {
801
+ position: absolute;
802
+ right: 12px;
803
+ }
804
+
805
+ .select-clear {
806
+ position: absolute;
807
+ right: 32px;
808
+ top: 50%;
809
+ transform: translateY(-50%);
810
+ display: inline-flex;
811
+ align-items: center;
812
+ justify-content: center;
813
+ flex: none;
814
+ width: var(--select-clear-size, 18px);
815
+ height: var(--select-clear-size, 18px);
816
+ margin: 0;
817
+ padding: 0;
818
+ border: none;
819
+ border-radius: var(--select-clear-border-radius, var(--radius, 4px));
820
+ background: var(--select-clear-background, transparent);
821
+ color: var(--select-clear-color, #666666);
822
+ font-size: var(--select-clear-font-size, 16px);
823
+ line-height: 1;
824
+ cursor: pointer;
825
+ }
826
+
827
+ .select-clear:focus-visible {
828
+ outline: var(--select-clear-focus-outline, 2px solid currentColor);
829
+ outline-offset: 2px;
830
+ }
831
+
832
+ .select-clear:hover {
833
+ background: var(--select-clear-hover-background, #ededed);
834
+ color: var(--select-clear-hover-color, #111111);
835
+ }
836
+
837
+ .select-clear:active {
838
+ background: var(--select-clear-pressed-background, #e0e0e0);
839
+ }
840
+
687
841
  .select-value {
688
842
  flex: 1;
689
843
  white-space: nowrap;
@@ -821,6 +975,18 @@
821
975
  );
822
976
  }
823
977
 
978
+ /* Pressed feedback comes last, and each selector carries enough specificity
979
+ to outrank its non-active counterpart above. An option is always hovered
980
+ when pressed and is often selected too, so a single low-specificity rule
981
+ earlier in the sheet would be correct in isolation and invisible in
982
+ practice -- which is how a pressed state ends up shipping as dead CSS.
983
+ A press on an already-selected row still has to confirm the tap landed. */
984
+ .select-option:active,
985
+ .select-option.selected:active,
986
+ .select-option.selected.highlighted:active {
987
+ background: var(--select-option-pressed-background, #ededed);
988
+ }
989
+
824
990
  .select-option-indicator {
825
991
  display: inline-flex;
826
992
  align-items: center;
@@ -26,6 +26,28 @@ export type OptionalSelectProperties = {
26
26
  searchable?: boolean;
27
27
  placeholder?: string;
28
28
  disabled?: boolean;
29
+ /**
30
+ * Renders the error treatment on the trigger — a red border that outranks
31
+ * the hover and focus border colours, so the field still reads as invalid
32
+ * while the user is in it. Independent of any validation the consumer runs;
33
+ * this is the server- or form-driven flag, matching `Input.forceError`.
34
+ */
35
+ error?: boolean;
36
+ /**
37
+ * Message shown below the trigger while `error` is true, in a `role="alert"`
38
+ * region wired to the combobox through `aria-describedby`. A string rather
39
+ * than a snippet, following `Input`'s `onErrorMessage`: the content is a
40
+ * sentence, and making it a snippet would mean every consumer rebuilds the
41
+ * alert semantics that make it announce.
42
+ */
43
+ errorMessage?: string;
44
+ /**
45
+ * Adds a clear (×) button to the trigger whenever there is a selection. It
46
+ * resets the value WITHOUT opening the menu, which is the whole point —
47
+ * clearing through the menu costs an open, a hunt and a second click.
48
+ * Hidden while `disabled`, and never rendered when nothing is selected.
49
+ */
50
+ clearable?: boolean;
29
51
  bottomContent?: Snippet;
30
52
  /**
31
53
  * Snippet for rendering a custom multi-select option indicator, receiving `{ checked, indeterminate }`.
@@ -97,6 +119,12 @@ export type OptionalSelectProperties = {
97
119
  };
98
120
  export type SelectEventProperties = {
99
121
  onchange?: (value: string[]) => void;
122
+ /**
123
+ * Fires when the clear button empties the selection, after `onchange` has
124
+ * already reported the new empty value. Use it to distinguish "cleared" from
125
+ * "deselected the last item", which `onchange` alone cannot tell apart.
126
+ */
127
+ onclear?: () => void;
100
128
  onopen?: () => void;
101
129
  onclose?: () => void;
102
130
  };