@juspay/svelte-ui-components 4.16.0 → 4.18.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 +561 -218
- package/dist/Select/properties.d.ts +46 -0
- package/dist-wc/index.js +1608 -1465
- package/package.json +1 -1
|
@@ -12,8 +12,13 @@
|
|
|
12
12
|
value = $bindable([]),
|
|
13
13
|
multiple = false,
|
|
14
14
|
searchable = false,
|
|
15
|
+
searchPosition = 'trigger',
|
|
15
16
|
placeholder = '',
|
|
16
17
|
disabled = false,
|
|
18
|
+
error = false,
|
|
19
|
+
errorMessage,
|
|
20
|
+
clearable = false,
|
|
21
|
+
onclear,
|
|
17
22
|
bottomContent,
|
|
18
23
|
optionIndicator,
|
|
19
24
|
showSelectAll = false,
|
|
@@ -40,6 +45,7 @@
|
|
|
40
45
|
|
|
41
46
|
let items: SelectItem[] = $derived(normalizeItems(rawItems));
|
|
42
47
|
let query = $state('');
|
|
48
|
+
let restoringFocus = false;
|
|
43
49
|
let highlightedIndex = $state(-1);
|
|
44
50
|
let containerEl: HTMLDivElement | null = $state(null);
|
|
45
51
|
let searchInputEl: HTMLInputElement | null = $state(null);
|
|
@@ -55,7 +61,44 @@
|
|
|
55
61
|
// default. The in-flow panel still honours the CSS var via its margin-top.
|
|
56
62
|
const PORTAL_DROPDOWN_GAP = 4;
|
|
57
63
|
|
|
58
|
-
const
|
|
64
|
+
const instanceId = $props.id();
|
|
65
|
+
const listboxId = `select-listbox-${instanceId}`;
|
|
66
|
+
const errorMessageId = `${listboxId}-error`;
|
|
67
|
+
|
|
68
|
+
/* `searchable` alone no longer says where the input goes, so every place
|
|
69
|
+
that used to branch on it now asks which of the two it meant. Filtering
|
|
70
|
+
still keys off `searchable`, since that is true in both placements. */
|
|
71
|
+
const searchInTrigger = $derived(searchable && searchPosition !== 'menu');
|
|
72
|
+
const searchInMenu = $derived(searchable && searchPosition === 'menu');
|
|
73
|
+
|
|
74
|
+
const hasErrorMessage = $derived(
|
|
75
|
+
error && typeof errorMessage === 'string' && errorMessage.trim().length > 0
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
/* Nothing selected means nothing to clear, so the control is not rendered at
|
|
79
|
+
all rather than rendered inert -- a disabled × that never enables reads as
|
|
80
|
+
broken. Same for `disabled`, where the whole field is inert already. */
|
|
81
|
+
const showClearButton = $derived(clearable && !disabled && value.length > 0);
|
|
82
|
+
|
|
83
|
+
function handleClear(event: MouseEvent): void {
|
|
84
|
+
/* The point of a trigger-level clear is that it does NOT open the menu.
|
|
85
|
+
The trigger's own click handler sits on the ancestor, so without this
|
|
86
|
+
the click would clear and then immediately open. */
|
|
87
|
+
event.stopPropagation();
|
|
88
|
+
if (value.length === 0) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
value = [];
|
|
92
|
+
onchange?.(value);
|
|
93
|
+
onclear?.();
|
|
94
|
+
if (searchable && searchInputEl !== null) {
|
|
95
|
+
restoringFocus = true;
|
|
96
|
+
searchInputEl.focus();
|
|
97
|
+
restoringFocus = false;
|
|
98
|
+
} else {
|
|
99
|
+
triggerEl?.focus();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
59
102
|
|
|
60
103
|
function getLabel(id: string): string {
|
|
61
104
|
const found = items.find((item) => item.id === id);
|
|
@@ -186,7 +229,7 @@
|
|
|
186
229
|
onopen?.();
|
|
187
230
|
highlightedIndex = -1;
|
|
188
231
|
query = '';
|
|
189
|
-
if (
|
|
232
|
+
if (searchInTrigger) {
|
|
190
233
|
await tick();
|
|
191
234
|
if (searchInputEl !== null) {
|
|
192
235
|
searchInputEl.focus();
|
|
@@ -210,6 +253,11 @@
|
|
|
210
253
|
} else {
|
|
211
254
|
value = [id];
|
|
212
255
|
close();
|
|
256
|
+
// Focus was in the menu's own search box, which is now gone; without
|
|
257
|
+
// this it falls back to <body> and the keyboard user loses their place.
|
|
258
|
+
if (searchInMenu) {
|
|
259
|
+
triggerEl?.focus();
|
|
260
|
+
}
|
|
213
261
|
}
|
|
214
262
|
onchange?.(value);
|
|
215
263
|
}
|
|
@@ -283,6 +331,28 @@
|
|
|
283
331
|
}
|
|
284
332
|
}
|
|
285
333
|
|
|
334
|
+
const FOCUSABLE =
|
|
335
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
336
|
+
|
|
337
|
+
/* Options carry tabindex="-1", so this is the search box plus whatever the
|
|
338
|
+
consumer pinned via bottomContent -- the panel's real tab stops. */
|
|
339
|
+
function menuFocusables(): HTMLElement[] {
|
|
340
|
+
return dropdownEl === null ? [] : [...dropdownEl.querySelectorAll<HTMLElement>(FOCUSABLE)];
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/* Leaving the menu forwards should continue from the trigger, as if the
|
|
344
|
+
panel had never been in the tab order -- which is literally true for a
|
|
345
|
+
portaled one, whose DOM position is the end of <body>. */
|
|
346
|
+
function focusAfterTrigger(): void {
|
|
347
|
+
if (triggerEl === null || typeof document === 'undefined') {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const tabbable = [...document.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
|
|
351
|
+
(element) => element.closest('.select-dropdown') === null
|
|
352
|
+
);
|
|
353
|
+
tabbable.at(tabbable.indexOf(triggerEl) + 1)?.focus();
|
|
354
|
+
}
|
|
355
|
+
|
|
286
356
|
function handleKeydown(event: KeyboardEvent): void {
|
|
287
357
|
if (disabled) {
|
|
288
358
|
return;
|
|
@@ -321,7 +391,7 @@
|
|
|
321
391
|
case 'Escape':
|
|
322
392
|
if (open) {
|
|
323
393
|
close();
|
|
324
|
-
if (!
|
|
394
|
+
if (!searchInTrigger && triggerEl !== null) {
|
|
325
395
|
triggerEl.focus();
|
|
326
396
|
}
|
|
327
397
|
}
|
|
@@ -334,14 +404,69 @@
|
|
|
334
404
|
}
|
|
335
405
|
}
|
|
336
406
|
break;
|
|
337
|
-
case 'Tab':
|
|
338
|
-
if (open) {
|
|
407
|
+
case 'Tab': {
|
|
408
|
+
if (!open) {
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
/* Outside the in-menu variant, Tab always means focus is leaving. */
|
|
412
|
+
if (!searchInMenu) {
|
|
339
413
|
close();
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
const active = document.activeElement;
|
|
417
|
+
if (event.shiftKey) {
|
|
418
|
+
/* Backwards out of the trigger genuinely leaves the widget; every
|
|
419
|
+
other Shift+Tab walks back through the menu and must not close
|
|
420
|
+
it, or the search box could never be left and re-entered. */
|
|
421
|
+
if (active === triggerEl) {
|
|
422
|
+
close();
|
|
423
|
+
} else if (active === searchInputEl) {
|
|
424
|
+
/* The trigger is not the portaled panel's DOM predecessor, so
|
|
425
|
+
native Shift+Tab would land outside the widget entirely. */
|
|
426
|
+
event.preventDefault();
|
|
427
|
+
triggerEl?.focus();
|
|
428
|
+
}
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
if (active === triggerEl) {
|
|
432
|
+
/* A portaled panel is appended to <body>, so it is not the
|
|
433
|
+
trigger's DOM-order successor and native Tab would skip the whole
|
|
434
|
+
menu. Moving focus explicitly makes the in-flow and portaled
|
|
435
|
+
variants answer Tab identically. */
|
|
436
|
+
event.preventDefault();
|
|
437
|
+
searchInputEl?.focus();
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
/* Forward Tab only leaves once there is nothing left to reach: the
|
|
441
|
+
search box is followed by any pinned bottom actions, which were
|
|
442
|
+
unreachable while Tab from the search closed the menu outright. */
|
|
443
|
+
if (active === menuFocusables().at(-1)) {
|
|
444
|
+
event.preventDefault();
|
|
445
|
+
close();
|
|
446
|
+
focusAfterTrigger();
|
|
340
447
|
}
|
|
341
448
|
break;
|
|
449
|
+
}
|
|
342
450
|
}
|
|
343
451
|
}
|
|
344
452
|
|
|
453
|
+
/* The panel handler exists for the pinned bottom actions, which have no
|
|
454
|
+
handler of their own. The search box already carries one, and letting its
|
|
455
|
+
bubbled event run a second time here moved the highlight twice per key. */
|
|
456
|
+
function handleMenuKeydown(event: KeyboardEvent): void {
|
|
457
|
+
if (event.target !== searchInputEl) {
|
|
458
|
+
handleKeydown(event);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/* Attached imperatively: the panel is a plain container, and a keydown
|
|
463
|
+
handler written in the template would demand an ARIA role that the
|
|
464
|
+
wrapper around the search box and the listbox must not claim. */
|
|
465
|
+
const menuKeys = (node: HTMLElement) => {
|
|
466
|
+
node.addEventListener('keydown', handleMenuKeydown);
|
|
467
|
+
return { destroy: () => node.removeEventListener('keydown', handleMenuKeydown) };
|
|
468
|
+
};
|
|
469
|
+
|
|
345
470
|
function handleSearchInput(event: Event): void {
|
|
346
471
|
if (!(event.target instanceof HTMLInputElement)) {
|
|
347
472
|
return;
|
|
@@ -354,7 +479,7 @@
|
|
|
354
479
|
}
|
|
355
480
|
|
|
356
481
|
function handleSearchFocus(): void {
|
|
357
|
-
if (!open) {
|
|
482
|
+
if (!open && !restoringFocus) {
|
|
358
483
|
openDropdown();
|
|
359
484
|
}
|
|
360
485
|
}
|
|
@@ -388,244 +513,337 @@
|
|
|
388
513
|
class="select {classes ?? ''}"
|
|
389
514
|
class:open
|
|
390
515
|
class:disabled
|
|
516
|
+
class:error
|
|
391
517
|
class:ghost={hierarchy === 'ghost'}
|
|
392
518
|
bind:this={containerEl}
|
|
393
519
|
{...typeof testId === 'string' ? { 'data-pw': testId, testID: testId } : {}}
|
|
394
520
|
>
|
|
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
|
-
|
|
521
|
+
<div class="select-control" class:has-clear={showClearButton}>
|
|
522
|
+
<div
|
|
523
|
+
class="select-trigger"
|
|
524
|
+
bind:this={triggerEl}
|
|
525
|
+
onclick={handleTriggerClick}
|
|
526
|
+
onkeydown={handleKeydown}
|
|
527
|
+
role="combobox"
|
|
528
|
+
aria-expanded={open}
|
|
529
|
+
aria-haspopup="listbox"
|
|
530
|
+
aria-controls={listboxId}
|
|
531
|
+
aria-invalid={error ? 'true' : null}
|
|
532
|
+
aria-describedby={hasErrorMessage ? errorMessageId : null}
|
|
533
|
+
{...highlightedOptionId !== null ? { 'aria-activedescendant': highlightedOptionId } : {}}
|
|
534
|
+
tabindex={disabled ? -1 : searchInTrigger ? -1 : 0}
|
|
535
|
+
>
|
|
536
|
+
{#if typeof leftIcon === 'string' && leftIcon.length > 0}
|
|
537
|
+
<Img
|
|
538
|
+
inlineSvg
|
|
539
|
+
src={leftIcon}
|
|
540
|
+
alt=""
|
|
541
|
+
fallback=""
|
|
542
|
+
classes="select-left-icon"
|
|
543
|
+
{...typeof leftIconTestId === 'string' ? { testId: leftIconTestId } : {}}
|
|
544
|
+
/>
|
|
545
|
+
{/if}
|
|
546
|
+
{#if multiple}
|
|
547
|
+
{#if typeof triggerSummary === 'function'}
|
|
548
|
+
{@render triggerSummary({ value, items })}
|
|
549
|
+
{#if searchInTrigger}
|
|
550
|
+
<input
|
|
551
|
+
class="select-search"
|
|
552
|
+
type="text"
|
|
553
|
+
value={query}
|
|
554
|
+
oninput={handleSearchInput}
|
|
555
|
+
onfocus={handleSearchFocus}
|
|
556
|
+
bind:this={searchInputEl}
|
|
557
|
+
placeholder={value.length === 0 ? placeholder : ''}
|
|
558
|
+
{disabled}
|
|
559
|
+
aria-invalid={error ? 'true' : null}
|
|
560
|
+
aria-describedby={hasErrorMessage ? errorMessageId : null}
|
|
561
|
+
autocomplete="off"
|
|
562
|
+
tabindex={disabled ? -1 : 0}
|
|
563
|
+
data-pw={typeof testId === 'string' ? `${testId}-search` : null}
|
|
564
|
+
testID={typeof testId === 'string' ? `${testId}-search` : null}
|
|
565
|
+
/>
|
|
566
|
+
{/if}
|
|
567
|
+
{:else}
|
|
568
|
+
{#each value as id (id)}
|
|
569
|
+
<Pill
|
|
570
|
+
text={getLabel(id)}
|
|
571
|
+
dismissible
|
|
572
|
+
{disabled}
|
|
573
|
+
ondismiss={() => removeItem(id)}
|
|
574
|
+
{...typeof testId === 'string' ? { testId: `${testId}-pill-${id}` } : {}}
|
|
575
|
+
/>
|
|
576
|
+
{/each}
|
|
577
|
+
{#if searchInTrigger}
|
|
578
|
+
<input
|
|
579
|
+
class="select-search"
|
|
580
|
+
type="text"
|
|
581
|
+
value={query}
|
|
582
|
+
oninput={handleSearchInput}
|
|
583
|
+
onfocus={handleSearchFocus}
|
|
584
|
+
bind:this={searchInputEl}
|
|
585
|
+
placeholder={value.length === 0 ? placeholder : ''}
|
|
586
|
+
{disabled}
|
|
587
|
+
aria-invalid={error ? 'true' : null}
|
|
588
|
+
aria-describedby={hasErrorMessage ? errorMessageId : null}
|
|
589
|
+
autocomplete="off"
|
|
590
|
+
tabindex={disabled ? -1 : 0}
|
|
591
|
+
data-pw={typeof testId === 'string' ? `${testId}-search` : null}
|
|
592
|
+
testID={typeof testId === 'string' ? `${testId}-search` : null}
|
|
593
|
+
/>
|
|
594
|
+
{:else if value.length === 0}
|
|
595
|
+
<span class="select-placeholder">{placeholder}</span>
|
|
596
|
+
{/if}
|
|
435
597
|
{/if}
|
|
598
|
+
{:else if searchInTrigger}
|
|
599
|
+
<input
|
|
600
|
+
class="select-search"
|
|
601
|
+
type="text"
|
|
602
|
+
value={open ? query : displayText}
|
|
603
|
+
oninput={handleSearchInput}
|
|
604
|
+
onfocus={handleSearchFocus}
|
|
605
|
+
bind:this={searchInputEl}
|
|
606
|
+
placeholder={searchPlaceholder}
|
|
607
|
+
{disabled}
|
|
608
|
+
aria-invalid={error ? 'true' : null}
|
|
609
|
+
aria-describedby={hasErrorMessage ? errorMessageId : null}
|
|
610
|
+
autocomplete="off"
|
|
611
|
+
tabindex={disabled ? -1 : 0}
|
|
612
|
+
data-pw={typeof testId === 'string' ? `${testId}-search` : null}
|
|
613
|
+
testID={typeof testId === 'string' ? `${testId}-search` : null}
|
|
614
|
+
/>
|
|
436
615
|
{: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}
|
|
616
|
+
<span class={displayText.length > 0 ? 'select-value' : 'select-placeholder'}>
|
|
617
|
+
{displayText.length > 0 ? displayText : placeholder}
|
|
618
|
+
</span>
|
|
464
619
|
{/if}
|
|
465
|
-
|
|
466
|
-
<
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
</
|
|
620
|
+
<!-- eslint-disable svelte/no-at-html-tags -->
|
|
621
|
+
<span class="select-arrow">{@html chevronDownSvg}</span>
|
|
622
|
+
</div>
|
|
623
|
+
{#if showClearButton}
|
|
624
|
+
<button
|
|
625
|
+
type="button"
|
|
626
|
+
class="select-clear"
|
|
627
|
+
aria-label="Clear selection"
|
|
628
|
+
onkeydown={(event) => {
|
|
629
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
630
|
+
event.stopPropagation();
|
|
631
|
+
}
|
|
632
|
+
}}
|
|
633
|
+
onclick={handleClear}
|
|
634
|
+
data-pw={typeof testId === 'string' ? `${testId}-clear` : null}
|
|
635
|
+
testID={typeof testId === 'string' ? `${testId}-clear` : null}
|
|
636
|
+
>
|
|
637
|
+
<span aria-hidden="true">×</span>
|
|
638
|
+
</button>
|
|
484
639
|
{/if}
|
|
485
|
-
<!-- eslint-disable svelte/no-at-html-tags -->
|
|
486
|
-
<span class="select-arrow">{@html chevronDownSvg}</span>
|
|
487
640
|
</div>
|
|
488
641
|
|
|
489
|
-
{#if
|
|
642
|
+
{#if hasErrorMessage}
|
|
490
643
|
<div
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
class
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
bind:clientHeight={dropdownHeight}
|
|
497
|
-
role="listbox"
|
|
498
|
-
id={listboxId}
|
|
499
|
-
aria-multiselectable={multiple}
|
|
500
|
-
style={portalStyle}
|
|
501
|
-
use:portalToBody
|
|
644
|
+
id={errorMessageId}
|
|
645
|
+
role="alert"
|
|
646
|
+
class="select-error-message"
|
|
647
|
+
data-pw={typeof testId === 'string' ? `${testId}-error-message` : null}
|
|
648
|
+
testID={typeof testId === 'string' ? `${testId}-error-message` : null}
|
|
502
649
|
>
|
|
503
|
-
{
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
650
|
+
{errorMessage}
|
|
651
|
+
</div>
|
|
652
|
+
{/if}
|
|
653
|
+
|
|
654
|
+
{#snippet menuOptions()}
|
|
655
|
+
{#if filteredItems.length === 0}
|
|
656
|
+
<div class="select-empty">No results</div>
|
|
657
|
+
{:else}
|
|
658
|
+
{#each optionRows as row, index (row.kind === 'select-all' ? 'select-all' : row.item.id)}
|
|
659
|
+
{#if row.kind === 'select-all'}
|
|
660
|
+
<div
|
|
661
|
+
class="select-option select-all"
|
|
662
|
+
class:multi={multiple}
|
|
663
|
+
class:selected={allFilteredSelected}
|
|
664
|
+
class:highlighted={index === highlightedIndex}
|
|
665
|
+
role="option"
|
|
666
|
+
id={`${listboxId}-option-${index}`}
|
|
667
|
+
aria-selected={allFilteredSelected}
|
|
668
|
+
aria-label={selectAllIndeterminate
|
|
669
|
+
? `${selectAllLabel}, ${selectedFilteredCount} of ${filteredItems.length} selected`
|
|
670
|
+
: selectAllLabel}
|
|
671
|
+
tabindex="-1"
|
|
672
|
+
{...typeof testId === 'string'
|
|
673
|
+
? { 'data-pw': `${testId}-select-all`, testID: `${testId}-select-all` }
|
|
674
|
+
: {}}
|
|
675
|
+
onclick={toggleSelectAll}
|
|
676
|
+
onmouseenter={() => (highlightedIndex = index)}
|
|
677
|
+
>
|
|
678
|
+
{#if typeof optionIndicator === 'function'}
|
|
679
|
+
{@render optionIndicator({
|
|
680
|
+
checked: allFilteredSelected,
|
|
681
|
+
indeterminate: selectAllIndeterminate
|
|
682
|
+
})}
|
|
683
|
+
{:else}
|
|
684
|
+
<span
|
|
685
|
+
class="select-option-indicator"
|
|
686
|
+
class:checked={allFilteredSelected}
|
|
687
|
+
class:indeterminate={selectAllIndeterminate}
|
|
688
|
+
aria-hidden="true"
|
|
689
|
+
data-checked={allFilteredSelected ? 'true' : 'false'}
|
|
690
|
+
data-pw={typeof testId === 'string' ? `${testId}-select-all-indicator` : null}
|
|
691
|
+
testID={typeof testId === 'string' ? `${testId}-select-all-indicator` : null}
|
|
692
|
+
>
|
|
693
|
+
{#if allFilteredSelected}
|
|
694
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
695
|
+
<span class="select-option-check">{@html checkmarkSvg}</span>
|
|
696
|
+
{:else if selectAllIndeterminate}
|
|
697
|
+
<span
|
|
698
|
+
class="select-option-dash"
|
|
699
|
+
data-pw={typeof testId === 'string' ? `${testId}-select-all-dash` : null}
|
|
700
|
+
testID={typeof testId === 'string' ? `${testId}-select-all-dash` : null}
|
|
701
|
+
></span>
|
|
702
|
+
{/if}
|
|
703
|
+
</span>
|
|
704
|
+
{/if}
|
|
705
|
+
{selectAllLabel}
|
|
706
|
+
</div>
|
|
707
|
+
{:else}
|
|
708
|
+
<div
|
|
709
|
+
class="select-option"
|
|
710
|
+
class:multi={multiple}
|
|
711
|
+
class:tickable={showSelectedTick && !multiple}
|
|
712
|
+
class:selected={value.includes(row.item.id)}
|
|
713
|
+
class:highlighted={index === highlightedIndex}
|
|
714
|
+
role="option"
|
|
715
|
+
id={`${listboxId}-option-${index}`}
|
|
716
|
+
aria-selected={value.includes(row.item.id)}
|
|
717
|
+
tabindex="-1"
|
|
718
|
+
{...typeof row.item.testId === 'string'
|
|
719
|
+
? { 'data-pw': row.item.testId, testID: row.item.testId }
|
|
720
|
+
: typeof itemTestId === 'string'
|
|
721
|
+
? {
|
|
722
|
+
'data-pw': `${itemTestId}-${row.item.id}`,
|
|
723
|
+
testID: `${itemTestId}-${row.item.id}`
|
|
724
|
+
}
|
|
725
|
+
: typeof testId === 'string'
|
|
726
|
+
? { 'data-pw': `${testId}-${row.item.id}`, testID: `${testId}-${row.item.id}` }
|
|
727
|
+
: {}}
|
|
728
|
+
onclick={() => selectItem(row.item.id)}
|
|
729
|
+
onmouseenter={() => (highlightedIndex = index)}
|
|
730
|
+
>
|
|
731
|
+
{#if multiple}
|
|
526
732
|
{#if typeof optionIndicator === 'function'}
|
|
527
733
|
{@render optionIndicator({
|
|
528
|
-
checked:
|
|
529
|
-
indeterminate:
|
|
734
|
+
checked: value.includes(row.item.id),
|
|
735
|
+
indeterminate: false
|
|
530
736
|
})}
|
|
531
737
|
{:else}
|
|
532
738
|
<span
|
|
533
739
|
class="select-option-indicator"
|
|
534
|
-
class:checked={
|
|
535
|
-
class:indeterminate={selectAllIndeterminate}
|
|
740
|
+
class:checked={value.includes(row.item.id)}
|
|
536
741
|
aria-hidden="true"
|
|
537
|
-
data-checked={
|
|
538
|
-
data-pw={typeof testId === 'string'
|
|
539
|
-
|
|
742
|
+
data-checked={value.includes(row.item.id) ? 'true' : 'false'}
|
|
743
|
+
data-pw={typeof testId === 'string'
|
|
744
|
+
? `${testId}-option-indicator-${row.item.id}`
|
|
745
|
+
: null}
|
|
746
|
+
testID={typeof testId === 'string'
|
|
747
|
+
? `${testId}-option-indicator-${row.item.id}`
|
|
748
|
+
: null}
|
|
540
749
|
>
|
|
541
|
-
{#if
|
|
750
|
+
{#if value.includes(row.item.id)}
|
|
542
751
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
543
752
|
<span class="select-option-check">{@html checkmarkSvg}</span>
|
|
544
|
-
{:else if selectAllIndeterminate}
|
|
545
|
-
<span
|
|
546
|
-
class="select-option-dash"
|
|
547
|
-
data-pw={typeof testId === 'string' ? `${testId}-select-all-dash` : null}
|
|
548
|
-
testID={typeof testId === 'string' ? `${testId}-select-all-dash` : null}
|
|
549
|
-
></span>
|
|
550
753
|
{/if}
|
|
551
754
|
</span>
|
|
552
755
|
{/if}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
class
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
<span class="select-option-tick" aria-hidden="true">{@html checkmarkSvg}</span>
|
|
618
|
-
{/if}
|
|
619
|
-
</div>
|
|
620
|
-
{/if}
|
|
621
|
-
{/each}
|
|
622
|
-
{/if}
|
|
623
|
-
{#if typeof bottomContent === 'function'}
|
|
624
|
-
<div class="select-bottom-content">
|
|
625
|
-
{@render bottomContent()}
|
|
756
|
+
{/if}
|
|
757
|
+
{#if typeof row.item.icon === 'string' && row.item.icon.length > 0}
|
|
758
|
+
<Img inlineSvg src={row.item.icon} alt="" fallback="" classes="select-option-icon" />
|
|
759
|
+
{/if}
|
|
760
|
+
<span class="select-option-label">{row.item.label}</span>
|
|
761
|
+
{#if showSelectedTick && !multiple && value.includes(row.item.id)}
|
|
762
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
763
|
+
<span class="select-option-tick" aria-hidden="true">{@html checkmarkSvg}</span>
|
|
764
|
+
{/if}
|
|
765
|
+
</div>
|
|
766
|
+
{/if}
|
|
767
|
+
{/each}
|
|
768
|
+
{/if}
|
|
769
|
+
{#if typeof bottomContent === 'function' && !searchInMenu}
|
|
770
|
+
<div class="select-bottom-content">
|
|
771
|
+
{@render bottomContent()}
|
|
772
|
+
</div>
|
|
773
|
+
{/if}
|
|
774
|
+
{/snippet}
|
|
775
|
+
|
|
776
|
+
{#if open && !disabled}
|
|
777
|
+
{#if searchInMenu}
|
|
778
|
+
<!-- The panel itself carries no role here: a listbox may only contain
|
|
779
|
+
options, so the search box has to be a SIBLING of the listbox rather
|
|
780
|
+
than a child of it. `aria-controls` on the trigger still points at
|
|
781
|
+
the inner element that actually holds the options. -->
|
|
782
|
+
<div
|
|
783
|
+
class="select-dropdown"
|
|
784
|
+
class:select-dropdown-right={dropdownAlign === 'right'}
|
|
785
|
+
class:select-dropdown-portal={usePortal}
|
|
786
|
+
bind:this={dropdownEl}
|
|
787
|
+
bind:clientWidth={dropdownWidth}
|
|
788
|
+
bind:clientHeight={dropdownHeight}
|
|
789
|
+
style={portalStyle}
|
|
790
|
+
use:menuKeys
|
|
791
|
+
use:portalToBody
|
|
792
|
+
>
|
|
793
|
+
<input
|
|
794
|
+
class="select-menu-search"
|
|
795
|
+
type="search"
|
|
796
|
+
value={query}
|
|
797
|
+
oninput={handleSearchInput}
|
|
798
|
+
onkeydown={handleKeydown}
|
|
799
|
+
bind:this={searchInputEl}
|
|
800
|
+
placeholder={placeholder.length > 0 ? placeholder : 'Search'}
|
|
801
|
+
aria-label="Search options"
|
|
802
|
+
aria-controls={listboxId}
|
|
803
|
+
autocomplete="off"
|
|
804
|
+
data-pw={typeof testId === 'string' ? `${testId}-menu-search` : null}
|
|
805
|
+
testID={typeof testId === 'string' ? `${testId}-menu-search` : null}
|
|
806
|
+
/>
|
|
807
|
+
<!-- tabindex="-1" because the list scrolls, and a scroll container is
|
|
808
|
+
a tab stop of its own in Chromium -- which swallowed the Tab that
|
|
809
|
+
should reach the pinned bottom actions. The options are navigated
|
|
810
|
+
from the search box via aria-activedescendant, so the list itself
|
|
811
|
+
never needs to hold focus. -->
|
|
812
|
+
<div
|
|
813
|
+
class="select-menu-list"
|
|
814
|
+
role="listbox"
|
|
815
|
+
id={listboxId}
|
|
816
|
+
aria-multiselectable={multiple}
|
|
817
|
+
tabindex="-1"
|
|
818
|
+
>
|
|
819
|
+
{@render menuOptions()}
|
|
626
820
|
</div>
|
|
627
|
-
|
|
628
|
-
|
|
821
|
+
{#if typeof bottomContent === 'function'}
|
|
822
|
+
<!-- Outside the listbox: a listbox may only contain options, so
|
|
823
|
+
pinned actions (clear-all, apply) would be invalid children and
|
|
824
|
+
assistive tech would announce them as options. -->
|
|
825
|
+
<div class="select-bottom-content">
|
|
826
|
+
{@render bottomContent()}
|
|
827
|
+
</div>
|
|
828
|
+
{/if}
|
|
829
|
+
</div>
|
|
830
|
+
{:else}
|
|
831
|
+
<div
|
|
832
|
+
class="select-dropdown"
|
|
833
|
+
class:select-dropdown-right={dropdownAlign === 'right'}
|
|
834
|
+
class:select-dropdown-portal={usePortal}
|
|
835
|
+
bind:this={dropdownEl}
|
|
836
|
+
bind:clientWidth={dropdownWidth}
|
|
837
|
+
bind:clientHeight={dropdownHeight}
|
|
838
|
+
role="listbox"
|
|
839
|
+
id={listboxId}
|
|
840
|
+
aria-multiselectable={multiple}
|
|
841
|
+
style={portalStyle}
|
|
842
|
+
use:portalToBody
|
|
843
|
+
>
|
|
844
|
+
{@render menuOptions()}
|
|
845
|
+
</div>
|
|
846
|
+
{/if}
|
|
629
847
|
{/if}
|
|
630
848
|
</div>
|
|
631
849
|
|
|
@@ -684,6 +902,84 @@
|
|
|
684
902
|
box-shadow: var(--select-trigger-focus-shadow, 0 0 0 2px rgba(37, 99, 235, 0.2));
|
|
685
903
|
}
|
|
686
904
|
|
|
905
|
+
/* Pressed feedback on the trigger. `:active` only, so it lasts exactly as
|
|
906
|
+
long as the pointer is down and never lingers as a stuck state the way a
|
|
907
|
+
class-driven one can. */
|
|
908
|
+
.select:not(.disabled) .select-trigger:active {
|
|
909
|
+
background: var(--select-trigger-pressed-background, #ededed);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/* The error border has to outrank BOTH hover and focus/open, or the field
|
|
913
|
+
stops reading as invalid the moment the user enters it -- which is the one
|
|
914
|
+
moment they most need to see it. Listed after them, at equal or higher
|
|
915
|
+
specificity, rather than reached for with !important. */
|
|
916
|
+
.select.error .select-trigger,
|
|
917
|
+
.select.error .select-trigger:hover,
|
|
918
|
+
.select.error .select-trigger:focus-within,
|
|
919
|
+
.select.error.open .select-trigger {
|
|
920
|
+
border-color: var(--select-trigger-error-border-color, #dc2626);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
.select.error .select-trigger:focus-within,
|
|
924
|
+
.select.error.open .select-trigger {
|
|
925
|
+
box-shadow: var(--select-trigger-error-shadow, 0 0 0 2px rgba(220, 38, 38, 0.2));
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
.select-error-message {
|
|
929
|
+
margin-top: var(--select-error-message-margin-top, 4px);
|
|
930
|
+
font-size: var(--select-error-message-font-size, 12px);
|
|
931
|
+
color: var(--select-error-message-color, #dc2626);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
.select-control {
|
|
935
|
+
position: relative;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
.select-control.has-clear .select-trigger {
|
|
939
|
+
padding-inline-end: calc(var(--select-clear-size, 18px) + 36px);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
.select-control.has-clear .select-arrow {
|
|
943
|
+
position: absolute;
|
|
944
|
+
right: 12px;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
.select-clear {
|
|
948
|
+
position: absolute;
|
|
949
|
+
right: 32px;
|
|
950
|
+
top: 50%;
|
|
951
|
+
transform: translateY(-50%);
|
|
952
|
+
display: inline-flex;
|
|
953
|
+
align-items: center;
|
|
954
|
+
justify-content: center;
|
|
955
|
+
flex: none;
|
|
956
|
+
width: var(--select-clear-size, 18px);
|
|
957
|
+
height: var(--select-clear-size, 18px);
|
|
958
|
+
margin: 0;
|
|
959
|
+
padding: 0;
|
|
960
|
+
border: none;
|
|
961
|
+
border-radius: var(--select-clear-border-radius, var(--radius, 4px));
|
|
962
|
+
background: var(--select-clear-background, transparent);
|
|
963
|
+
color: var(--select-clear-color, #666666);
|
|
964
|
+
font-size: var(--select-clear-font-size, 16px);
|
|
965
|
+
line-height: 1;
|
|
966
|
+
cursor: pointer;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
.select-clear:focus-visible {
|
|
970
|
+
outline: var(--select-clear-focus-outline, 2px solid currentColor);
|
|
971
|
+
outline-offset: 2px;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
.select-clear:hover {
|
|
975
|
+
background: var(--select-clear-hover-background, #ededed);
|
|
976
|
+
color: var(--select-clear-hover-color, #111111);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
.select-clear:active {
|
|
980
|
+
background: var(--select-clear-pressed-background, #e0e0e0);
|
|
981
|
+
}
|
|
982
|
+
|
|
687
983
|
.select-value {
|
|
688
984
|
flex: 1;
|
|
689
985
|
white-space: nowrap;
|
|
@@ -756,6 +1052,41 @@
|
|
|
756
1052
|
z-index: var(--select-dropdown-z-index, 10);
|
|
757
1053
|
}
|
|
758
1054
|
|
|
1055
|
+
/* With the search box inside the panel, the PANEL must stop scrolling and
|
|
1056
|
+
the list scroll instead -- otherwise the search box scrolls away as soon
|
|
1057
|
+
as the user moves down the results, which is the one control they still
|
|
1058
|
+
need. The max-height moves down with the scrolling, so the panel keeps
|
|
1059
|
+
growing to fit the search box plus a list of the same height as before. */
|
|
1060
|
+
.select-dropdown:has(> .select-menu-list) {
|
|
1061
|
+
max-height: none;
|
|
1062
|
+
overflow-y: visible;
|
|
1063
|
+
display: flex;
|
|
1064
|
+
flex-direction: column;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
.select-menu-list {
|
|
1068
|
+
max-height: var(--select-dropdown-max-height, 200px);
|
|
1069
|
+
overflow-y: auto;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
.select-menu-search {
|
|
1073
|
+
flex: none;
|
|
1074
|
+
margin: var(--select-menu-search-margin, 6px);
|
|
1075
|
+
padding: var(--select-menu-search-padding, 6px 8px);
|
|
1076
|
+
border: var(--select-menu-search-border, 1px solid #cccccc);
|
|
1077
|
+
border-radius: var(--select-menu-search-border-radius, var(--radius, 4px));
|
|
1078
|
+
background: var(--select-menu-search-background, #ffffff);
|
|
1079
|
+
color: var(--select-menu-search-color, inherit);
|
|
1080
|
+
font-family: inherit;
|
|
1081
|
+
font-size: var(--select-menu-search-font-size, inherit);
|
|
1082
|
+
outline: none;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
.select-menu-search:focus-visible {
|
|
1086
|
+
border-color: var(--select-menu-search-focus-border-color, #2563eb);
|
|
1087
|
+
box-shadow: var(--select-menu-search-focus-shadow, 0 0 0 2px rgba(37, 99, 235, 0.2));
|
|
1088
|
+
}
|
|
1089
|
+
|
|
759
1090
|
.select-dropdown-right {
|
|
760
1091
|
left: auto;
|
|
761
1092
|
right: var(--select-dropdown-right, 0);
|
|
@@ -821,6 +1152,18 @@
|
|
|
821
1152
|
);
|
|
822
1153
|
}
|
|
823
1154
|
|
|
1155
|
+
/* Pressed feedback comes last, and each selector carries enough specificity
|
|
1156
|
+
to outrank its non-active counterpart above. An option is always hovered
|
|
1157
|
+
when pressed and is often selected too, so a single low-specificity rule
|
|
1158
|
+
earlier in the sheet would be correct in isolation and invisible in
|
|
1159
|
+
practice -- which is how a pressed state ends up shipping as dead CSS.
|
|
1160
|
+
A press on an already-selected row still has to confirm the tap landed. */
|
|
1161
|
+
.select-option:active,
|
|
1162
|
+
.select-option.selected:active,
|
|
1163
|
+
.select-option.selected.highlighted:active {
|
|
1164
|
+
background: var(--select-option-pressed-background, #ededed);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
824
1167
|
.select-option-indicator {
|
|
825
1168
|
display: inline-flex;
|
|
826
1169
|
align-items: center;
|