@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.
- package/dist/Select/Select.svelte +256 -90
- package/dist/Select/properties.d.ts +28 -0
- package/dist-wc/index.js +2016 -849
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
{
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
466
|
-
<
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
</
|
|
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
|
};
|