@transferwise/components 0.0.0-experimental-91bc693 → 0.0.0-experimental-c023984

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.
Files changed (43) hide show
  1. package/build/inputs/SelectInput.js +86 -34
  2. package/build/inputs/SelectInput.js.map +1 -1
  3. package/build/inputs/SelectInput.mjs +88 -36
  4. package/build/inputs/SelectInput.mjs.map +1 -1
  5. package/build/main.css +10 -0
  6. package/build/styles/inputs/SelectInput.css +10 -0
  7. package/build/styles/main.css +10 -0
  8. package/build/types/inputs/SelectInput.d.ts.map +1 -1
  9. package/build/types/uploadInput/UploadInput.d.ts.map +1 -1
  10. package/build/types/uploadInput/uploadButton/UploadButton.d.ts +1 -1
  11. package/build/types/uploadInput/uploadButton/UploadButton.d.ts.map +1 -1
  12. package/build/types/uploadInput/uploadItem/UploadItem.d.ts +14 -9
  13. package/build/types/uploadInput/uploadItem/UploadItem.d.ts.map +1 -1
  14. package/build/types/uploadInput/uploadItem/UploadItemLink.d.ts +5 -5
  15. package/build/types/uploadInput/uploadItem/UploadItemLink.d.ts.map +1 -1
  16. package/build/uploadInput/UploadInput.js +2 -25
  17. package/build/uploadInput/UploadInput.js.map +1 -1
  18. package/build/uploadInput/UploadInput.mjs +3 -26
  19. package/build/uploadInput/UploadInput.mjs.map +1 -1
  20. package/build/uploadInput/uploadButton/UploadButton.js +4 -6
  21. package/build/uploadInput/uploadButton/UploadButton.js.map +1 -1
  22. package/build/uploadInput/uploadButton/UploadButton.mjs +5 -7
  23. package/build/uploadInput/uploadButton/UploadButton.mjs.map +1 -1
  24. package/build/uploadInput/uploadItem/UploadItem.js +30 -22
  25. package/build/uploadInput/uploadItem/UploadItem.js.map +1 -1
  26. package/build/uploadInput/uploadItem/UploadItem.mjs +28 -22
  27. package/build/uploadInput/uploadItem/UploadItem.mjs.map +1 -1
  28. package/build/uploadInput/uploadItem/UploadItemLink.js +3 -5
  29. package/build/uploadInput/uploadItem/UploadItemLink.js.map +1 -1
  30. package/build/uploadInput/uploadItem/UploadItemLink.mjs +3 -5
  31. package/build/uploadInput/uploadItem/UploadItemLink.mjs.map +1 -1
  32. package/package.json +4 -3
  33. package/src/inputs/SelectInput.css +10 -0
  34. package/src/inputs/SelectInput.less +12 -0
  35. package/src/inputs/SelectInput.story.tsx +20 -0
  36. package/src/inputs/SelectInput.tsx +116 -37
  37. package/src/main.css +10 -0
  38. package/src/uploadInput/UploadInput.spec.tsx +3 -4
  39. package/src/uploadInput/UploadInput.story.tsx +2 -2
  40. package/src/uploadInput/UploadInput.tsx +2 -28
  41. package/src/uploadInput/uploadButton/UploadButton.tsx +151 -146
  42. package/src/uploadInput/uploadItem/UploadItem.tsx +141 -122
  43. package/src/uploadInput/uploadItem/UploadItemLink.tsx +25 -23
@@ -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
 
@@ -89,6 +93,11 @@ function dedupeSelectInputOptionItem<T>(
89
93
  return { ...item, value: undefined };
90
94
  }
91
95
 
96
+ /**
97
+ * Sets the `value` of duplicate option items to `undefined`, hiding them when
98
+ * rendered. Indexes are kept intact within groups to preserve the active item
99
+ * between filter changes when possible.
100
+ */
92
101
  function dedupeSelectInputItems<T>(
93
102
  items: readonly SelectInputItem<T>[],
94
103
  ): SelectInputItem<T | undefined>[] {
@@ -112,20 +121,23 @@ function dedupeSelectInputItems<T>(
112
121
  });
113
122
  }
114
123
 
115
- function filterSelectInputOptionItem<T>(item: SelectInputOptionItem<T>, needle: string) {
124
+ function selectInputOptionItemIncludesNeedle<T>(item: SelectInputOptionItem<T>, needle: string) {
116
125
  return inferSearchableStrings(item.filterMatchers ?? item.value).some((haystack) =>
117
126
  haystack.includes(needle),
118
127
  );
119
128
  }
120
129
 
121
- function filterSelectInputItems<T>(items: readonly SelectInputItem<T>[], needle: string) {
130
+ function filterSelectInputItems<T>(
131
+ items: readonly SelectInputItem<T>[],
132
+ predicate: (item: SelectInputOptionItem<T>) => boolean,
133
+ ) {
122
134
  return items.filter((item) => {
123
135
  switch (item.type) {
124
136
  case 'option': {
125
- return filterSelectInputOptionItem(item, needle);
137
+ return predicate(item);
126
138
  }
127
139
  case 'group': {
128
- return item.options.some((option) => filterSelectInputOptionItem(option, needle));
140
+ return item.options.some((option) => predicate(option));
129
141
  }
130
142
  default:
131
143
  }
@@ -271,12 +283,15 @@ export function SelectInput<T = string, M extends boolean = false>({
271
283
  }, [handleClose, open]);
272
284
 
273
285
  const [filterQuery, _setFilterQuery] = useState('');
286
+ const deferredFilterQuery = useDeferredValue(filterQuery);
274
287
  const setFilterQuery = useEffectEvent((query: string) => {
275
288
  _setFilterQuery(query);
276
- onFilterChange({
277
- query,
278
- queryNormalized: query ? searchableString(query) : null,
279
- });
289
+ if (query !== filterQuery) {
290
+ onFilterChange({
291
+ query,
292
+ queryNormalized: query ? searchableString(query) : null,
293
+ });
294
+ }
280
295
  });
281
296
 
282
297
  const triggerRef = useRef<HTMLButtonElement | null>(null);
@@ -294,9 +309,7 @@ export function SelectInput<T = string, M extends boolean = false>({
294
309
  multiple={multiple}
295
310
  defaultValue={defaultValue}
296
311
  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}
312
+ by={compareValues}
300
313
  disabled={disabled}
301
314
  onChange={
302
315
  ((value) => {
@@ -349,8 +362,8 @@ export function SelectInput<T = string, M extends boolean = false>({
349
362
  content: !placeholderShown ? (
350
363
  <SelectInputOptionContentWithinTriggerContext.Provider value>
351
364
  {multiple && Array.isArray(value)
352
- ? value
353
- .map((option: NonNullable<T>) => renderValue(option, true))
365
+ ? (value as readonly NonNullable<T>[])
366
+ .map((option) => renderValue(option, true))
354
367
  .filter((node) => node != null)
355
368
  .join(', ')
356
369
  : renderValue(value as NonNullable<T>, true)}
@@ -379,9 +392,7 @@ export function SelectInput<T = string, M extends boolean = false>({
379
392
  setOpen(false);
380
393
  }}
381
394
  onCloseEnd={() => {
382
- if (filterQuery !== '') {
383
- setFilterQuery('');
384
- }
395
+ setFilterQuery('');
385
396
  }}
386
397
  >
387
398
  <SelectInputOptions
@@ -392,7 +403,7 @@ export function SelectInput<T = string, M extends boolean = false>({
392
403
  filterPlaceholder={filterPlaceholder}
393
404
  searchInputRef={searchInputRef}
394
405
  listboxRef={listboxRef}
395
- filterQuery={filterQuery}
406
+ filterQuery={deferredFilterQuery}
396
407
  onFilterChange={setFilterQuery}
397
408
  />
398
409
  </OptionsOverlay>
@@ -506,7 +517,48 @@ function SelectInputOptions<T = string>({
506
517
  }
507
518
  return undefined;
508
519
  }, [filterQuery, filterable]);
509
- const resultsEmpty = needle != null && filterSelectInputItems(items, needle).length === 0;
520
+ useEffect(() => {
521
+ if (needle) {
522
+ // Ensure having an active option while filtering
523
+ requestAnimationFrame(() => {
524
+ if (
525
+ controllerRef.current != null &&
526
+ !controllerRef.current.hasAttribute('aria-activedescendant')
527
+ ) {
528
+ // Activate first option via synthetic key press
529
+ controllerRef.current.dispatchEvent(
530
+ new KeyboardEvent('keydown', { key: 'Home', bubbles: true }),
531
+ );
532
+ }
533
+ });
534
+ }
535
+ }, [controllerRef, needle]);
536
+
537
+ const filteredItems: readonly SelectInputItem<NonNullable<T> | undefined>[] =
538
+ needle != null
539
+ ? filterSelectInputItems(dedupeSelectInputItems(items), (item) =>
540
+ selectInputOptionItemIncludesNeedle(item, needle),
541
+ )
542
+ : items;
543
+ const resultsEmpty = needle != null && filteredItems.length === 0;
544
+
545
+ const virtualized = filteredItems.length > MAX_ITEMS_WITHOUT_VIRTUALIZATION;
546
+
547
+ // Items shown once shall be kept mounted until the needle changes, otherwise
548
+ // the scroll position may jump around inadvertently. Pattern adopted from:
549
+ // https://inokawa.github.io/virtua/?path=/story/advanced-keep-offscreen-items--append-only
550
+ const [mountedIndexes, setMountedIndexes] = useState<number[]>([]);
551
+ useEffect(() => {
552
+ // Ensure the 'End' key works as intended by keeping the last item mounted
553
+ setMountedIndexes((prevMountedIndexes) => {
554
+ const indexes = new Set(prevMountedIndexes);
555
+ indexes.add(filteredItems.length - 1);
556
+ return [...indexes]; // Sorting is redundant by nature here
557
+ });
558
+ }, [
559
+ needle, // Needed as `filteredItems.length` may be equal between two updates
560
+ filteredItems.length,
561
+ ]);
510
562
 
511
563
  const listboxContainerRef = useRef<HTMLDivElement>(null);
512
564
  useEffect(() => {
@@ -522,6 +574,19 @@ function SelectInputOptions<T = string>({
522
574
  const statusId = useId();
523
575
  const listboxId = useId();
524
576
 
577
+ const getItemNode = (index: number) => {
578
+ const item = filteredItems[index];
579
+ return (
580
+ <SelectInputItemView
581
+ // eslint-disable-next-line react/no-array-index-key
582
+ key={index}
583
+ item={item}
584
+ renderValue={renderValue}
585
+ needle={needle}
586
+ />
587
+ );
588
+ };
589
+
525
590
  return (
526
591
  <ListboxBase.Options
527
592
  as={SelectInputOptionsContainer}
@@ -533,12 +598,6 @@ function SelectInputOptions<T = string>({
533
598
  controllerRef.current.setAttribute('aria-activedescendant', value);
534
599
  } else {
535
600
  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
601
  }
543
602
  }
544
603
  }}
@@ -549,7 +608,7 @@ function SelectInputOptions<T = string>({
549
608
  ref={searchInputRef}
550
609
  shape="rectangle"
551
610
  placeholder={filterPlaceholder}
552
- value={filterQuery}
611
+ defaultValue={filterQuery}
553
612
  aria-controls={listboxId}
554
613
  aria-describedby={showStatus ? statusId : undefined}
555
614
  onKeyDown={(event) => {
@@ -560,6 +619,9 @@ function SelectInputOptions<T = string>({
560
619
  }
561
620
  }}
562
621
  onChange={(event) => {
622
+ // Free up resources and ensure not to go out of bounds when the
623
+ // resulting item count is less than before
624
+ setMountedIndexes([]);
563
625
  onFilterChange(event.currentTarget.value);
564
626
  }}
565
627
  />
@@ -571,7 +633,9 @@ function SelectInputOptions<T = string>({
571
633
  tabIndex={-1}
572
634
  className={classNames(
573
635
  'np-select-input-listbox-container',
574
- items.some((item) => item.type === 'group') &&
636
+ virtualized && 'np-select-input-listbox-container--virtualized',
637
+ needle == null && // Groups aren't shown when filtering
638
+ items.some((item) => item.type === 'group') &&
575
639
  'np-select-input-listbox-container--has-group',
576
640
  )}
577
641
  >
@@ -590,15 +654,27 @@ function SelectInputOptions<T = string>({
590
654
  tabIndex={0}
591
655
  className="np-select-input-listbox"
592
656
  >
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
- ))}
657
+ {!virtualized ? (
658
+ filteredItems.map((_, index) => getItemNode(index))
659
+ ) : (
660
+ <Virtualizer
661
+ key={needle}
662
+ count={filteredItems.length}
663
+ keepMounted={mountedIndexes}
664
+ scrollRef={listboxRef} // `VList` doesn't expose this
665
+ onRangeChange={(startIndex, endIndex) => {
666
+ setMountedIndexes((prevMountedIndexes) => {
667
+ const indexes = new Set(prevMountedIndexes);
668
+ for (let index = startIndex; index <= endIndex; index += 1) {
669
+ indexes.add(index);
670
+ }
671
+ return [...indexes].sort((a, b) => a - b);
672
+ });
673
+ }}
674
+ >
675
+ {(index) => getItemNode(index)}
676
+ </Virtualizer>
677
+ )}
602
678
  </div>
603
679
 
604
680
  {renderFooter != null ? (
@@ -639,7 +715,10 @@ function SelectInputItemView<T = string>({
639
715
  }: SelectInputItemViewProps<T>) {
640
716
  switch (item.type) {
641
717
  case 'option': {
642
- if (item.value != null && (needle == null || filterSelectInputOptionItem(item, needle))) {
718
+ if (
719
+ item.value != null &&
720
+ (needle == null || selectInputOptionItemIncludesNeedle(item, needle))
721
+ ) {
643
722
  return (
644
723
  <SelectInputOption value={item.value} disabled={item.disabled}>
645
724
  {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);
@@ -8,6 +8,7 @@ import { mockMatchMedia, render, screen, waitFor, waitForElementToBeRemoved } fr
8
8
 
9
9
  import UploadInput, { UploadInputProps } from './UploadInput';
10
10
  import { TEST_IDS as UPLOAD_BUTTON_TEST_IDS } from './uploadButton/UploadButton';
11
+ import { TEST_IDS as UPLOAD_ITEM_TEST_IDS } from './uploadItem/UploadItem';
11
12
 
12
13
  const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTimeAsync });
13
14
 
@@ -138,8 +139,7 @@ describe('UploadInput', () => {
138
139
  onFilesChange,
139
140
  });
140
141
 
141
- const fileToDeleteTestId = `upload-item-${files[0].id}`;
142
- const fileToDelete = screen.getByTestId(fileToDeleteTestId);
142
+ const fileToDelete = screen.getAllByTestId(UPLOAD_ITEM_TEST_IDS.uploadItem)[0];
143
143
  within(fileToDelete).getByLabelText('Remove file', { exact: false }).click();
144
144
  await act(async () => {
145
145
  await jest.runOnlyPendingTimersAsync();
@@ -190,8 +190,7 @@ describe('UploadInput', () => {
190
190
  onFilesChange,
191
191
  });
192
192
 
193
- const fileToDeleteTestId = `upload-item-${files[0].id}`;
194
- const fileToDelete = screen.getByTestId(fileToDeleteTestId);
193
+ const fileToDelete = screen.getAllByTestId(UPLOAD_ITEM_TEST_IDS.uploadItem)[0];
195
194
  within(fileToDelete).getByLabelText('Remove file', { exact: false }).click();
196
195
  await act(async () => {
197
196
  await jest.runOnlyPendingTimersAsync();
@@ -51,7 +51,7 @@ export const WithinField: Story = Template.bind({});
51
51
  WithinField.decorators = [
52
52
  (Story) => (
53
53
  <Field message="Something went wrong" sentiment="negative">
54
- <Story />
55
- </Field>
54
+ <Story />
55
+ </Field>
56
56
  ),
57
57
  ];
@@ -128,9 +128,6 @@ const UploadInput = ({
128
128
  sizeLimitErrorMessage,
129
129
  uploadButtonTitle,
130
130
  }: UploadInputProps) => {
131
- const uploadButtonRef = useRef<HTMLInputElement | null>(null);
132
- const uploadItemRefs = useRef<(HTMLButtonElement | HTMLAnchorElement | null)[]>([]);
133
-
134
131
  const inputAttributes = useInputAttributes({ nonLabelable: true });
135
132
 
136
133
  const [markedFileForDelete, setMarkedFileForDelete] = useState<UploadedFile | null>(null);
@@ -183,37 +180,19 @@ const UploadInput = ({
183
180
  if (status === Status.FAILED) {
184
181
  // If removing a failed upload, we're just updating the view
185
182
  removeFileFromList(file);
186
- // Move focus management logic here for failed status
187
- manageFocusAfterRemoval(file);
188
183
  } else if (onDeleteFile && id) {
189
184
  // Set status to PROCESSING
190
185
  modifyFileInList(file, { status: Status.PROCESSING, error: undefined });
191
186
 
192
187
  // Notify host app about deletion
193
188
  onDeleteFile(id)
194
- .then(() => {
195
- removeFileFromList(file);
196
- // Move focus management logic here for successful deletion
197
- manageFocusAfterRemoval(file);
198
- })
189
+ .then(() => removeFileFromList(file))
199
190
  .catch((error) => {
200
191
  modifyFileInList(file, { error: error as UploadError });
201
192
  });
202
193
  }
203
194
  };
204
195
 
205
- // Extracted focus management logic into a separate function
206
- const manageFocusAfterRemoval = (file: UploadedFile) => {
207
- const index = uploadedFiles.findIndex((f) => f.id === file.id);
208
- const nextFocusIndex = index >= uploadedFiles.length - 1 ? index - 1 : index + 1;
209
-
210
- if (nextFocusIndex >= 0 && uploadItemRefs.current[nextFocusIndex]) {
211
- uploadItemRefs.current[nextFocusIndex].focus();
212
- } else if (uploadButtonRef.current) {
213
- uploadButtonRef.current.focus();
214
- }
215
- };
216
-
217
196
  function handleFileUploadFailure(file: File, failureMessage: string) {
218
197
  const { name } = file;
219
198
  const id = generateFileId(file);
@@ -328,13 +307,9 @@ const UploadInput = ({
328
307
  className={classNames('np-upload-input', className, { disabled })}
329
308
  {...inputAttributes}
330
309
  >
331
- {uploadedFiles.map((file, index) => (
310
+ {uploadedFiles.map((file) => (
332
311
  <UploadItem
333
- ref={(el) => {
334
- uploadItemRefs.current[index] = el;
335
- }}
336
312
  key={file.id}
337
- testid={String(file.id)}
338
313
  file={file}
339
314
  singleFileUpload={!multiple}
340
315
  canDelete={
@@ -351,7 +326,6 @@ const UploadInput = ({
351
326
  ))}
352
327
  {(multiple || (!multiple && !uploadedFiles.length)) && (
353
328
  <UploadButton
354
- ref={uploadButtonRef}
355
329
  id={id}
356
330
  uploadButtonTitle={uploadButtonTitle}
357
331
  disabled={areMaximumFilesUploadedAlready() || disabled}