@transferwise/components 0.0.0-experimental-297fe5b → 0.0.0-experimental-c84d637
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/build/i18n/zh-HK.json +0 -2
- package/build/i18n/zh-HK.json.js +0 -2
- package/build/i18n/zh-HK.json.js.map +1 -1
- package/build/i18n/zh-HK.json.mjs +0 -2
- package/build/i18n/zh-HK.json.mjs.map +1 -1
- package/build/inputs/SelectInput.js +81 -34
- package/build/inputs/SelectInput.js.map +1 -1
- package/build/inputs/SelectInput.mjs +83 -36
- package/build/inputs/SelectInput.mjs.map +1 -1
- package/build/main.css +10 -0
- package/build/styles/inputs/SelectInput.css +10 -0
- package/build/styles/main.css +10 -0
- package/build/types/inputs/SelectInput.d.ts.map +1 -1
- package/package.json +5 -4
- package/src/i18n/zh-HK.json +0 -2
- package/src/inputs/SelectInput.css +10 -0
- package/src/inputs/SelectInput.less +12 -0
- package/src/inputs/SelectInput.story.tsx +20 -0
- package/src/inputs/SelectInput.tsx +111 -37
- package/src/main.css +10 -0
|
@@ -60,6 +60,11 @@
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
&--virtualized {
|
|
64
|
+
/* The wrapping element shrinks this as needed */
|
|
65
|
+
height: 100vh;
|
|
66
|
+
}
|
|
67
|
+
|
|
63
68
|
&--has-group {
|
|
64
69
|
scroll-padding-top: var(--size-32);
|
|
65
70
|
}
|
|
@@ -71,6 +76,13 @@
|
|
|
71
76
|
|
|
72
77
|
.focus-ring();
|
|
73
78
|
--ring-outline-offset: calc(-1 * var(--ring-outline-width));
|
|
79
|
+
|
|
80
|
+
.np-select-input-listbox-container--virtualized & {
|
|
81
|
+
/* Adopted from `VList` in virtua: https://github.com/inokawa/virtua/blob/7f6ed5b37df6b480d4ff350f3960067c5b3519d2/src/react/VList.tsx#L113-L116 */
|
|
82
|
+
overflow-y: auto;
|
|
83
|
+
contain: strict;
|
|
84
|
+
height: 100%;
|
|
85
|
+
}
|
|
74
86
|
}
|
|
75
87
|
|
|
76
88
|
.np-select-input-separator-item {
|
|
@@ -322,6 +322,26 @@ export const Advanced: Story<Month> = {
|
|
|
322
322
|
},
|
|
323
323
|
};
|
|
324
324
|
|
|
325
|
+
export const ManyItems: Story<string, true> = {
|
|
326
|
+
args: {
|
|
327
|
+
multiple: true,
|
|
328
|
+
items: Array.from({ length: 1000 }, (_, index) => ({
|
|
329
|
+
type: 'option',
|
|
330
|
+
value: String(index + 1),
|
|
331
|
+
})),
|
|
332
|
+
renderValue: (value, withinTrigger) =>
|
|
333
|
+
withinTrigger ? (
|
|
334
|
+
value
|
|
335
|
+
) : (
|
|
336
|
+
<SelectInputOptionContent
|
|
337
|
+
title={value}
|
|
338
|
+
description={Number(value) % 10 === 0 ? 'Divisible by 10' : undefined}
|
|
339
|
+
/>
|
|
340
|
+
),
|
|
341
|
+
filterable: true,
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
|
|
325
345
|
export const WithinDrawer: Story<Currency> = {
|
|
326
346
|
args: CurrenciesArgs,
|
|
327
347
|
decorators: [
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
createContext,
|
|
7
7
|
forwardRef,
|
|
8
8
|
useContext,
|
|
9
|
+
useDeferredValue,
|
|
9
10
|
useEffect,
|
|
10
11
|
useId,
|
|
11
12
|
useMemo,
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
useState,
|
|
14
15
|
} from 'react';
|
|
15
16
|
import { useIntl } from 'react-intl';
|
|
17
|
+
import { Virtualizer } from 'virtua';
|
|
16
18
|
|
|
17
19
|
import { useEffectEvent } from '../common/hooks/useEffectEvent';
|
|
18
20
|
import { useScreenSize } from '../common/hooks/useScreenSize';
|
|
@@ -29,6 +31,8 @@ import { InputGroup } from './InputGroup';
|
|
|
29
31
|
import { SearchInput } from './SearchInput';
|
|
30
32
|
import messages from './SelectInput.messages';
|
|
31
33
|
|
|
34
|
+
const MAX_ITEMS_WITHOUT_VIRTUALIZATION = 50;
|
|
35
|
+
|
|
32
36
|
function searchableString(value: string) {
|
|
33
37
|
return value.trim().replace(/\s+/gu, ' ').normalize('NFKC').toLowerCase();
|
|
34
38
|
}
|
|
@@ -40,7 +44,7 @@ function inferSearchableStrings(value: unknown) {
|
|
|
40
44
|
|
|
41
45
|
if (typeof value === 'object' && value != null) {
|
|
42
46
|
return Object.values(value)
|
|
43
|
-
.filter((innerValue)
|
|
47
|
+
.filter((innerValue) => typeof innerValue === 'string')
|
|
44
48
|
.map((innerValue) => searchableString(innerValue));
|
|
45
49
|
}
|
|
46
50
|
|
|
@@ -112,20 +116,23 @@ function dedupeSelectInputItems<T>(
|
|
|
112
116
|
});
|
|
113
117
|
}
|
|
114
118
|
|
|
115
|
-
function
|
|
119
|
+
function selectInputOptionItemIncludesNeedle<T>(item: SelectInputOptionItem<T>, needle: string) {
|
|
116
120
|
return inferSearchableStrings(item.filterMatchers ?? item.value).some((haystack) =>
|
|
117
121
|
haystack.includes(needle),
|
|
118
122
|
);
|
|
119
123
|
}
|
|
120
124
|
|
|
121
|
-
function filterSelectInputItems<T>(
|
|
125
|
+
function filterSelectInputItems<T>(
|
|
126
|
+
items: readonly SelectInputItem<T>[],
|
|
127
|
+
predicate: (item: SelectInputOptionItem<T>) => boolean,
|
|
128
|
+
) {
|
|
122
129
|
return items.filter((item) => {
|
|
123
130
|
switch (item.type) {
|
|
124
131
|
case 'option': {
|
|
125
|
-
return
|
|
132
|
+
return predicate(item);
|
|
126
133
|
}
|
|
127
134
|
case 'group': {
|
|
128
|
-
return item.options.some((option) =>
|
|
135
|
+
return item.options.some((option) => predicate(option));
|
|
129
136
|
}
|
|
130
137
|
default:
|
|
131
138
|
}
|
|
@@ -271,12 +278,15 @@ export function SelectInput<T = string, M extends boolean = false>({
|
|
|
271
278
|
}, [handleClose, open]);
|
|
272
279
|
|
|
273
280
|
const [filterQuery, _setFilterQuery] = useState('');
|
|
281
|
+
const deferredFilterQuery = useDeferredValue(filterQuery);
|
|
274
282
|
const setFilterQuery = useEffectEvent((query: string) => {
|
|
275
283
|
_setFilterQuery(query);
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
284
|
+
if (query !== filterQuery) {
|
|
285
|
+
onFilterChange({
|
|
286
|
+
query,
|
|
287
|
+
queryNormalized: query ? searchableString(query) : null,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
280
290
|
});
|
|
281
291
|
|
|
282
292
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
@@ -294,9 +304,7 @@ export function SelectInput<T = string, M extends boolean = false>({
|
|
|
294
304
|
multiple={multiple}
|
|
295
305
|
defaultValue={defaultValue}
|
|
296
306
|
value={controlledValue}
|
|
297
|
-
|
|
298
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
299
|
-
by={compareValues as any}
|
|
307
|
+
by={compareValues}
|
|
300
308
|
disabled={disabled}
|
|
301
309
|
onChange={
|
|
302
310
|
((value) => {
|
|
@@ -349,8 +357,8 @@ export function SelectInput<T = string, M extends boolean = false>({
|
|
|
349
357
|
content: !placeholderShown ? (
|
|
350
358
|
<SelectInputOptionContentWithinTriggerContext.Provider value>
|
|
351
359
|
{multiple && Array.isArray(value)
|
|
352
|
-
? value
|
|
353
|
-
.map((option
|
|
360
|
+
? (value as readonly NonNullable<T>[])
|
|
361
|
+
.map((option) => renderValue(option, true))
|
|
354
362
|
.filter((node) => node != null)
|
|
355
363
|
.join(', ')
|
|
356
364
|
: renderValue(value as NonNullable<T>, true)}
|
|
@@ -379,9 +387,7 @@ export function SelectInput<T = string, M extends boolean = false>({
|
|
|
379
387
|
setOpen(false);
|
|
380
388
|
}}
|
|
381
389
|
onCloseEnd={() => {
|
|
382
|
-
|
|
383
|
-
setFilterQuery('');
|
|
384
|
-
}
|
|
390
|
+
setFilterQuery('');
|
|
385
391
|
}}
|
|
386
392
|
>
|
|
387
393
|
<SelectInputOptions
|
|
@@ -392,7 +398,7 @@ export function SelectInput<T = string, M extends boolean = false>({
|
|
|
392
398
|
filterPlaceholder={filterPlaceholder}
|
|
393
399
|
searchInputRef={searchInputRef}
|
|
394
400
|
listboxRef={listboxRef}
|
|
395
|
-
filterQuery={
|
|
401
|
+
filterQuery={deferredFilterQuery}
|
|
396
402
|
onFilterChange={setFilterQuery}
|
|
397
403
|
/>
|
|
398
404
|
</OptionsOverlay>
|
|
@@ -506,7 +512,48 @@ function SelectInputOptions<T = string>({
|
|
|
506
512
|
}
|
|
507
513
|
return undefined;
|
|
508
514
|
}, [filterQuery, filterable]);
|
|
509
|
-
|
|
515
|
+
useEffect(() => {
|
|
516
|
+
if (needle) {
|
|
517
|
+
// Ensure having an active option while filtering
|
|
518
|
+
requestAnimationFrame(() => {
|
|
519
|
+
if (
|
|
520
|
+
controllerRef.current != null &&
|
|
521
|
+
!controllerRef.current.hasAttribute('aria-activedescendant')
|
|
522
|
+
) {
|
|
523
|
+
// Activate first option via synthetic key press
|
|
524
|
+
controllerRef.current.dispatchEvent(
|
|
525
|
+
new KeyboardEvent('keydown', { key: 'Home', bubbles: true }),
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}, [controllerRef, needle]);
|
|
531
|
+
|
|
532
|
+
const filteredItems: readonly SelectInputItem<NonNullable<T> | undefined>[] =
|
|
533
|
+
needle != null
|
|
534
|
+
? filterSelectInputItems(dedupeSelectInputItems(items), (item) =>
|
|
535
|
+
selectInputOptionItemIncludesNeedle(item, needle),
|
|
536
|
+
)
|
|
537
|
+
: items;
|
|
538
|
+
const resultsEmpty = needle != null && filteredItems.length === 0;
|
|
539
|
+
|
|
540
|
+
const virtualized = filteredItems.length > MAX_ITEMS_WITHOUT_VIRTUALIZATION;
|
|
541
|
+
|
|
542
|
+
// Items shown once shall be kept mounted until the needle changes, otherwise
|
|
543
|
+
// the scroll position may jump around inadvertently. Pattern adopted from:
|
|
544
|
+
// https://inokawa.github.io/virtua/?path=/story/advanced-keep-offscreen-items--append-only
|
|
545
|
+
const [mountedIndexes, setMountedIndexes] = useState<number[]>([]);
|
|
546
|
+
useEffect(() => {
|
|
547
|
+
// Ensure the 'End' key works as intended by keeping the last item mounted
|
|
548
|
+
setMountedIndexes((prevMountedIndexes) => {
|
|
549
|
+
const indexes = new Set(prevMountedIndexes);
|
|
550
|
+
indexes.add(filteredItems.length - 1);
|
|
551
|
+
return [...indexes]; // Sorting is redundant by nature here
|
|
552
|
+
});
|
|
553
|
+
}, [
|
|
554
|
+
needle, // Needed as `filteredItems.length` may be equal between two updates
|
|
555
|
+
filteredItems.length,
|
|
556
|
+
]);
|
|
510
557
|
|
|
511
558
|
const listboxContainerRef = useRef<HTMLDivElement>(null);
|
|
512
559
|
useEffect(() => {
|
|
@@ -522,6 +569,19 @@ function SelectInputOptions<T = string>({
|
|
|
522
569
|
const statusId = useId();
|
|
523
570
|
const listboxId = useId();
|
|
524
571
|
|
|
572
|
+
const getItemNode = (index: number) => {
|
|
573
|
+
const item = filteredItems[index];
|
|
574
|
+
return (
|
|
575
|
+
<SelectInputItemView
|
|
576
|
+
// eslint-disable-next-line react/no-array-index-key
|
|
577
|
+
key={index}
|
|
578
|
+
item={item}
|
|
579
|
+
renderValue={renderValue}
|
|
580
|
+
needle={needle}
|
|
581
|
+
/>
|
|
582
|
+
);
|
|
583
|
+
};
|
|
584
|
+
|
|
525
585
|
return (
|
|
526
586
|
<ListboxBase.Options
|
|
527
587
|
as={SelectInputOptionsContainer}
|
|
@@ -533,12 +593,6 @@ function SelectInputOptions<T = string>({
|
|
|
533
593
|
controllerRef.current.setAttribute('aria-activedescendant', value);
|
|
534
594
|
} else {
|
|
535
595
|
controllerRef.current.removeAttribute('aria-activedescendant');
|
|
536
|
-
if (filterQuery) {
|
|
537
|
-
// Ensure having an active option while filtering
|
|
538
|
-
controllerRef.current.dispatchEvent(
|
|
539
|
-
new KeyboardEvent('keydown', { key: 'Home', bubbles: true }),
|
|
540
|
-
);
|
|
541
|
-
}
|
|
542
596
|
}
|
|
543
597
|
}
|
|
544
598
|
}}
|
|
@@ -549,7 +603,7 @@ function SelectInputOptions<T = string>({
|
|
|
549
603
|
ref={searchInputRef}
|
|
550
604
|
shape="rectangle"
|
|
551
605
|
placeholder={filterPlaceholder}
|
|
552
|
-
|
|
606
|
+
defaultValue={filterQuery}
|
|
553
607
|
aria-controls={listboxId}
|
|
554
608
|
aria-describedby={showStatus ? statusId : undefined}
|
|
555
609
|
onKeyDown={(event) => {
|
|
@@ -560,6 +614,9 @@ function SelectInputOptions<T = string>({
|
|
|
560
614
|
}
|
|
561
615
|
}}
|
|
562
616
|
onChange={(event) => {
|
|
617
|
+
// Free up resources and ensure not to go out of bounds when the
|
|
618
|
+
// resulting item count is less than before
|
|
619
|
+
setMountedIndexes([]);
|
|
563
620
|
onFilterChange(event.currentTarget.value);
|
|
564
621
|
}}
|
|
565
622
|
/>
|
|
@@ -571,7 +628,9 @@ function SelectInputOptions<T = string>({
|
|
|
571
628
|
tabIndex={-1}
|
|
572
629
|
className={classNames(
|
|
573
630
|
'np-select-input-listbox-container',
|
|
574
|
-
|
|
631
|
+
virtualized && 'np-select-input-listbox-container--virtualized',
|
|
632
|
+
needle == null && // Groups aren't shown when filtering
|
|
633
|
+
items.some((item) => item.type === 'group') &&
|
|
575
634
|
'np-select-input-listbox-container--has-group',
|
|
576
635
|
)}
|
|
577
636
|
>
|
|
@@ -590,15 +649,27 @@ function SelectInputOptions<T = string>({
|
|
|
590
649
|
tabIndex={0}
|
|
591
650
|
className="np-select-input-listbox"
|
|
592
651
|
>
|
|
593
|
-
{
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
652
|
+
{!virtualized ? (
|
|
653
|
+
filteredItems.map((_, index) => getItemNode(index))
|
|
654
|
+
) : (
|
|
655
|
+
<Virtualizer
|
|
656
|
+
key={needle}
|
|
657
|
+
count={filteredItems.length}
|
|
658
|
+
keepMounted={mountedIndexes}
|
|
659
|
+
scrollRef={listboxRef} // `VList` doesn't expose this
|
|
660
|
+
onRangeChange={(startIndex, endIndex) => {
|
|
661
|
+
setMountedIndexes((prevMountedIndexes) => {
|
|
662
|
+
const indexes = new Set(prevMountedIndexes);
|
|
663
|
+
for (let index = startIndex; index <= endIndex; index += 1) {
|
|
664
|
+
indexes.add(index);
|
|
665
|
+
}
|
|
666
|
+
return [...indexes].sort((a, b) => a - b);
|
|
667
|
+
});
|
|
668
|
+
}}
|
|
669
|
+
>
|
|
670
|
+
{(index) => getItemNode(index)}
|
|
671
|
+
</Virtualizer>
|
|
672
|
+
)}
|
|
602
673
|
</div>
|
|
603
674
|
|
|
604
675
|
{renderFooter != null ? (
|
|
@@ -639,7 +710,10 @@ function SelectInputItemView<T = string>({
|
|
|
639
710
|
}: SelectInputItemViewProps<T>) {
|
|
640
711
|
switch (item.type) {
|
|
641
712
|
case 'option': {
|
|
642
|
-
if (
|
|
713
|
+
if (
|
|
714
|
+
item.value != null &&
|
|
715
|
+
(needle == null || selectInputOptionItemIncludesNeedle(item, needle))
|
|
716
|
+
) {
|
|
643
717
|
return (
|
|
644
718
|
<SelectInputOption value={item.value} disabled={item.disabled}>
|
|
645
719
|
{renderValue(item.value, false)}
|
package/src/main.css
CHANGED
|
@@ -2655,6 +2655,10 @@ html:not([dir="rtl"]) .np-flow-navigation--sm .np-flow-navigation__stepper {
|
|
|
2655
2655
|
height: auto;
|
|
2656
2656
|
}
|
|
2657
2657
|
}
|
|
2658
|
+
.np-select-input-listbox-container--virtualized {
|
|
2659
|
+
/* The wrapping element shrinks this as needed */
|
|
2660
|
+
height: 100vh;
|
|
2661
|
+
}
|
|
2658
2662
|
.np-select-input-listbox-container--has-group {
|
|
2659
2663
|
scroll-padding-top: 32px;
|
|
2660
2664
|
scroll-padding-top: var(--size-32);
|
|
@@ -2673,6 +2677,12 @@ html:not([dir="rtl"]) .np-flow-navigation--sm .np-flow-navigation__stepper {
|
|
|
2673
2677
|
outline: var(--ring-outline-color) solid var(--ring-outline-width);
|
|
2674
2678
|
outline-offset: var(--ring-outline-offset);
|
|
2675
2679
|
}
|
|
2680
|
+
.np-select-input-listbox-container--virtualized .np-select-input-listbox {
|
|
2681
|
+
/* Adopted from `VList` in virtua: https://github.com/inokawa/virtua/blob/7f6ed5b37df6b480d4ff350f3960067c5b3519d2/src/react/VList.tsx#L113-L116 */
|
|
2682
|
+
overflow-y: auto;
|
|
2683
|
+
contain: strict;
|
|
2684
|
+
height: 100%;
|
|
2685
|
+
}
|
|
2676
2686
|
.np-select-input-separator-item {
|
|
2677
2687
|
margin: 8px;
|
|
2678
2688
|
margin: var(--size-8);
|