@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.
@@ -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): innerValue is string => typeof innerValue === 'string')
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 filterSelectInputOptionItem<T>(item: SelectInputOptionItem<T>, needle: string) {
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>(items: readonly SelectInputItem<T>[], needle: string) {
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 filterSelectInputOptionItem(item, needle);
132
+ return predicate(item);
126
133
  }
127
134
  case 'group': {
128
- return item.options.some((option) => filterSelectInputOptionItem(option, needle));
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
- onFilterChange({
277
- query,
278
- queryNormalized: query ? searchableString(query) : null,
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
- // TODO: Remove assertion when upgrading TypeScript to v5
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: NonNullable<T>) => renderValue(option, true))
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
- if (filterQuery !== '') {
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={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
- const resultsEmpty = needle != null && filterSelectInputItems(items, needle).length === 0;
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
- value={filterQuery}
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
- items.some((item) => item.type === 'group') &&
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
- {(needle != null ? dedupeSelectInputItems(items) : items).map((item, index) => (
594
- <SelectInputItemView
595
- // eslint-disable-next-line react/no-array-index-key
596
- key={index}
597
- item={item}
598
- renderValue={renderValue}
599
- needle={needle}
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 (item.value != null && (needle == null || filterSelectInputOptionItem(item, needle))) {
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);