@transferwise/components 46.51.0 → 46.52.1

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/i18n/pt.json +2 -0
  2. package/build/i18n/pt.json.js +2 -0
  3. package/build/i18n/pt.json.js.map +1 -1
  4. package/build/i18n/pt.json.mjs +2 -0
  5. package/build/i18n/pt.json.mjs.map +1 -1
  6. package/build/i18n/zh-CN.json +2 -0
  7. package/build/i18n/zh-CN.json.js +2 -0
  8. package/build/i18n/zh-CN.json.js.map +1 -1
  9. package/build/i18n/zh-CN.json.mjs +2 -0
  10. package/build/i18n/zh-CN.json.mjs.map +1 -1
  11. package/build/inputs/SelectInput.js +107 -36
  12. package/build/inputs/SelectInput.js.map +1 -1
  13. package/build/inputs/SelectInput.mjs +109 -38
  14. package/build/inputs/SelectInput.mjs.map +1 -1
  15. package/build/main.css +10 -0
  16. package/build/styles/inputs/SelectInput.css +10 -0
  17. package/build/styles/main.css +10 -0
  18. package/build/typeahead/Typeahead.js +63 -59
  19. package/build/typeahead/Typeahead.js.map +1 -1
  20. package/build/typeahead/Typeahead.messages.js +12 -0
  21. package/build/typeahead/Typeahead.messages.js.map +1 -0
  22. package/build/typeahead/Typeahead.messages.mjs +10 -0
  23. package/build/typeahead/Typeahead.messages.mjs.map +1 -0
  24. package/build/typeahead/Typeahead.mjs +63 -59
  25. package/build/typeahead/Typeahead.mjs.map +1 -1
  26. package/build/types/inputs/SelectInput.d.ts.map +1 -1
  27. package/build/types/typeahead/Typeahead.d.ts +2 -1
  28. package/build/types/typeahead/Typeahead.d.ts.map +1 -1
  29. package/build/types/typeahead/Typeahead.messages.d.ts +9 -0
  30. package/build/types/typeahead/Typeahead.messages.d.ts.map +1 -0
  31. package/package.json +5 -4
  32. package/src/i18n/pt.json +2 -0
  33. package/src/i18n/zh-CN.json +2 -0
  34. package/src/inputs/SelectInput.css +10 -0
  35. package/src/inputs/SelectInput.less +12 -0
  36. package/src/inputs/SelectInput.story.tsx +20 -0
  37. package/src/inputs/SelectInput.tsx +144 -46
  38. package/src/main.css +10 -0
  39. package/src/typeahead/Typeahead.messages.ts +9 -0
  40. package/src/typeahead/Typeahead.rtl.spec.tsx +13 -1
  41. package/src/typeahead/Typeahead.spec.js +12 -10
  42. package/src/typeahead/Typeahead.story.tsx +194 -195
  43. package/src/typeahead/Typeahead.tsx +16 -9
@@ -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,22 +44,13 @@ 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
 
47
51
  return [];
48
52
  }
49
53
 
50
- const SelectInputTriggerButtonPropsContext = createContext<{
51
- ref?: React.ForwardedRef<HTMLButtonElement | null>;
52
- id?: string;
53
- onClick?: (event: React.MouseEvent) => void;
54
- onKeyDown?: (event: React.KeyboardEvent) => void;
55
- [key: string]: unknown;
56
- }>({});
57
- const SelectInputOptionContentWithinTriggerContext = createContext(false);
58
-
59
54
  export interface SelectInputOptionItem<T = string> {
60
55
  type: 'option';
61
56
  value: T;
@@ -89,6 +84,11 @@ function dedupeSelectInputOptionItem<T>(
89
84
  return { ...item, value: undefined };
90
85
  }
91
86
 
87
+ /**
88
+ * Sets the `value` of duplicate option items to `undefined`, hiding them when
89
+ * rendered. Indexes are kept intact within groups to preserve the active item
90
+ * between filter changes when possible.
91
+ */
92
92
  function dedupeSelectInputItems<T>(
93
93
  items: readonly SelectInputItem<T>[],
94
94
  ): SelectInputItem<T | undefined>[] {
@@ -112,20 +112,23 @@ function dedupeSelectInputItems<T>(
112
112
  });
113
113
  }
114
114
 
115
- function filterSelectInputOptionItem<T>(item: SelectInputOptionItem<T>, needle: string) {
115
+ function selectInputOptionItemIncludesNeedle<T>(item: SelectInputOptionItem<T>, needle: string) {
116
116
  return inferSearchableStrings(item.filterMatchers ?? item.value).some((haystack) =>
117
117
  haystack.includes(needle),
118
118
  );
119
119
  }
120
120
 
121
- function filterSelectInputItems<T>(items: readonly SelectInputItem<T>[], needle: string) {
121
+ function filterSelectInputItems<T>(
122
+ items: readonly SelectInputItem<T>[],
123
+ predicate: (item: SelectInputOptionItem<T>) => boolean,
124
+ ) {
122
125
  return items.filter((item) => {
123
126
  switch (item.type) {
124
127
  case 'option': {
125
- return filterSelectInputOptionItem(item, needle);
128
+ return predicate(item);
126
129
  }
127
130
  case 'group': {
128
- return item.options.some((option) => filterSelectInputOptionItem(option, needle));
131
+ return item.options.some((option) => predicate(option));
129
132
  }
130
133
  default:
131
134
  }
@@ -271,12 +274,15 @@ export function SelectInput<T = string, M extends boolean = false>({
271
274
  }, [handleClose, open]);
272
275
 
273
276
  const [filterQuery, _setFilterQuery] = useState('');
277
+ const deferredFilterQuery = useDeferredValue(filterQuery);
274
278
  const setFilterQuery = useEffectEvent((query: string) => {
275
279
  _setFilterQuery(query);
276
- onFilterChange({
277
- query,
278
- queryNormalized: query ? searchableString(query) : null,
279
- });
280
+ if (query !== filterQuery) {
281
+ onFilterChange({
282
+ query,
283
+ queryNormalized: query ? searchableString(query) : null,
284
+ });
285
+ }
280
286
  });
281
287
 
282
288
  const triggerRef = useRef<HTMLButtonElement | null>(null);
@@ -294,9 +300,7 @@ export function SelectInput<T = string, M extends boolean = false>({
294
300
  multiple={multiple}
295
301
  defaultValue={defaultValue}
296
302
  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}
303
+ by={compareValues}
300
304
  disabled={disabled}
301
305
  onChange={
302
306
  ((value) => {
@@ -349,8 +353,8 @@ export function SelectInput<T = string, M extends boolean = false>({
349
353
  content: !placeholderShown ? (
350
354
  <SelectInputOptionContentWithinTriggerContext.Provider value>
351
355
  {multiple && Array.isArray(value)
352
- ? value
353
- .map((option: NonNullable<T>) => renderValue(option, true))
356
+ ? (value as readonly NonNullable<T>[])
357
+ .map((option) => renderValue(option, true))
354
358
  .filter((node) => node != null)
355
359
  .join(', ')
356
360
  : renderValue(value as NonNullable<T>, true)}
@@ -379,9 +383,7 @@ export function SelectInput<T = string, M extends boolean = false>({
379
383
  setOpen(false);
380
384
  }}
381
385
  onCloseEnd={() => {
382
- if (filterQuery !== '') {
383
- setFilterQuery('');
384
- }
386
+ setFilterQuery('');
385
387
  }}
386
388
  >
387
389
  <SelectInputOptions
@@ -392,7 +394,7 @@ export function SelectInput<T = string, M extends boolean = false>({
392
394
  filterPlaceholder={filterPlaceholder}
393
395
  searchInputRef={searchInputRef}
394
396
  listboxRef={listboxRef}
395
- filterQuery={filterQuery}
397
+ filterQuery={deferredFilterQuery}
396
398
  onFilterChange={setFilterQuery}
397
399
  />
398
400
  </OptionsOverlay>
@@ -402,6 +404,14 @@ export function SelectInput<T = string, M extends boolean = false>({
402
404
  );
403
405
  }
404
406
 
407
+ const SelectInputTriggerButtonPropsContext = createContext<{
408
+ ref?: React.ForwardedRef<HTMLButtonElement | null>;
409
+ id?: string;
410
+ onClick?: (event: React.MouseEvent) => void;
411
+ onKeyDown?: (event: React.KeyboardEvent) => void;
412
+ [key: string]: unknown;
413
+ }>({});
414
+
405
415
  type SelectInputTriggerButtonElementType = 'button' | React.ComponentType;
406
416
 
407
417
  export type SelectInputTriggerButtonProps<
@@ -506,7 +516,51 @@ function SelectInputOptions<T = string>({
506
516
  }
507
517
  return undefined;
508
518
  }, [filterQuery, filterable]);
509
- const resultsEmpty = needle != null && filterSelectInputItems(items, needle).length === 0;
519
+ useEffect(() => {
520
+ if (needle) {
521
+ // Ensure having an active option while filtering.
522
+ // Without `requestAnimationFrame` upon which React depends for scheduling
523
+ // updates, the active status would only show for a split second and then
524
+ // disappear inadvertently.
525
+ requestAnimationFrame(() => {
526
+ if (
527
+ controllerRef.current != null &&
528
+ !controllerRef.current.hasAttribute('aria-activedescendant')
529
+ ) {
530
+ // Activate first option via synthetic key press
531
+ controllerRef.current.dispatchEvent(
532
+ new KeyboardEvent('keydown', { key: 'Home', bubbles: true }),
533
+ );
534
+ }
535
+ });
536
+ }
537
+ }, [controllerRef, needle]);
538
+
539
+ const filteredItems: readonly SelectInputItem<NonNullable<T> | undefined>[] =
540
+ needle != null
541
+ ? filterSelectInputItems(dedupeSelectInputItems(items), (item) =>
542
+ selectInputOptionItemIncludesNeedle(item, needle),
543
+ )
544
+ : items;
545
+ const resultsEmpty = needle != null && filteredItems.length === 0;
546
+
547
+ const virtualized = filteredItems.length > MAX_ITEMS_WITHOUT_VIRTUALIZATION;
548
+
549
+ // Items shown once shall be kept mounted until the needle changes, otherwise
550
+ // the scroll position may jump around inadvertently. Pattern adopted from:
551
+ // https://inokawa.github.io/virtua/?path=/story/advanced-keep-offscreen-items--append-only
552
+ const [mountedIndexes, setMountedIndexes] = useState<number[]>([]);
553
+ useEffect(() => {
554
+ // Ensure the 'End' key works as intended by keeping the last item mounted
555
+ setMountedIndexes((prevMountedIndexes) => {
556
+ const indexes = new Set(prevMountedIndexes);
557
+ indexes.add(filteredItems.length - 1);
558
+ return [...indexes]; // Sorting is redundant by nature here
559
+ });
560
+ }, [
561
+ needle, // Needed as `filteredItems.length` may be equal between two updates
562
+ filteredItems.length,
563
+ ]);
510
564
 
511
565
  const listboxContainerRef = useRef<HTMLDivElement>(null);
512
566
  useEffect(() => {
@@ -522,6 +576,19 @@ function SelectInputOptions<T = string>({
522
576
  const statusId = useId();
523
577
  const listboxId = useId();
524
578
 
579
+ const getItemNode = (index: number) => {
580
+ const item = filteredItems[index];
581
+ return (
582
+ <SelectInputItemView
583
+ // eslint-disable-next-line react/no-array-index-key
584
+ key={index}
585
+ item={item}
586
+ renderValue={renderValue}
587
+ needle={needle}
588
+ />
589
+ );
590
+ };
591
+
525
592
  return (
526
593
  <ListboxBase.Options
527
594
  as={SelectInputOptionsContainer}
@@ -533,12 +600,6 @@ function SelectInputOptions<T = string>({
533
600
  controllerRef.current.setAttribute('aria-activedescendant', value);
534
601
  } else {
535
602
  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
603
  }
543
604
  }
544
605
  }}
@@ -549,7 +610,7 @@ function SelectInputOptions<T = string>({
549
610
  ref={searchInputRef}
550
611
  shape="rectangle"
551
612
  placeholder={filterPlaceholder}
552
- value={filterQuery}
613
+ defaultValue={filterQuery}
553
614
  aria-controls={listboxId}
554
615
  aria-describedby={showStatus ? statusId : undefined}
555
616
  onKeyDown={(event) => {
@@ -560,6 +621,9 @@ function SelectInputOptions<T = string>({
560
621
  }
561
622
  }}
562
623
  onChange={(event) => {
624
+ // Free up resources and ensure not to go out of bounds when the
625
+ // resulting item count is less than before
626
+ setMountedIndexes([]);
563
627
  onFilterChange(event.currentTarget.value);
564
628
  }}
565
629
  />
@@ -571,7 +635,9 @@ function SelectInputOptions<T = string>({
571
635
  tabIndex={-1}
572
636
  className={clsx(
573
637
  'np-select-input-listbox-container',
574
- items.some((item) => item.type === 'group') &&
638
+ virtualized && 'np-select-input-listbox-container--virtualized',
639
+ needle == null && // Groups aren't shown when filtering
640
+ items.some((item) => item.type === 'group') &&
575
641
  'np-select-input-listbox-container--has-group',
576
642
  )}
577
643
  >
@@ -590,15 +656,35 @@ function SelectInputOptions<T = string>({
590
656
  tabIndex={0}
591
657
  className="np-select-input-listbox"
592
658
  >
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
- ))}
659
+ {!virtualized ? (
660
+ filteredItems.map((_, index) => getItemNode(index))
661
+ ) : (
662
+ <Virtualizer
663
+ key={needle}
664
+ count={filteredItems.length}
665
+ keepMounted={mountedIndexes}
666
+ scrollRef={listboxRef} // `VList` doesn't expose this
667
+ onRangeChange={(startIndex, endIndex) => {
668
+ setMountedIndexes((prevMountedIndexes) => {
669
+ const indexes = new Set(prevMountedIndexes);
670
+ for (let index = startIndex; index <= endIndex; index += 1) {
671
+ indexes.add(index);
672
+ }
673
+ return [...indexes].sort((a, b) => a - b);
674
+ });
675
+ }}
676
+ >
677
+ {(index) => (
678
+ // The position of each item can't be inferred by browsers when
679
+ // virtualizing, as some of the items may not be in the DOM
680
+ <SelectInputItemsCountContext.Provider value={filteredItems.length}>
681
+ <SelectInputItemPositionContext.Provider value={index + 1}>
682
+ {getItemNode(index)}
683
+ </SelectInputItemPositionContext.Provider>
684
+ </SelectInputItemsCountContext.Provider>
685
+ )}
686
+ </Virtualizer>
687
+ )}
602
688
  </div>
603
689
 
604
690
  {renderFooter != null ? (
@@ -639,7 +725,10 @@ function SelectInputItemView<T = string>({
639
725
  }: SelectInputItemViewProps<T>) {
640
726
  switch (item.type) {
641
727
  case 'option': {
642
- if (item.value != null && (needle == null || filterSelectInputOptionItem(item, needle))) {
728
+ if (
729
+ item.value != null &&
730
+ (needle == null || selectInputOptionItemIncludesNeedle(item, needle))
731
+ ) {
643
732
  return (
644
733
  <SelectInputOption value={item.value} disabled={item.disabled}>
645
734
  {renderValue(item.value, false)}
@@ -701,6 +790,9 @@ function SelectInputGroupItemView<T = string>({
701
790
  );
702
791
  }
703
792
 
793
+ const SelectInputItemsCountContext = createContext<number | undefined>(undefined);
794
+ const SelectInputItemPositionContext = createContext<number | undefined>(undefined);
795
+
704
796
  interface SelectInputOptionProps<T = string> {
705
797
  value: T;
706
798
  disabled?: boolean;
@@ -708,10 +800,14 @@ interface SelectInputOptionProps<T = string> {
708
800
  }
709
801
 
710
802
  function SelectInputOption<T = string>({ value, disabled, children }: SelectInputOptionProps<T>) {
803
+ const itemsCount = useContext(SelectInputItemsCountContext);
804
+ const itemPosition = useContext(SelectInputItemPositionContext);
711
805
  return (
712
806
  <ListboxBase.Option
713
807
  as="div"
714
808
  value={value}
809
+ aria-setsize={itemsCount}
810
+ aria-posinset={itemPosition}
715
811
  disabled={disabled}
716
812
  className={({ active, disabled: uiDisabled }) =>
717
813
  clsx(
@@ -737,6 +833,8 @@ function SelectInputOption<T = string>({ value, disabled, children }: SelectInpu
737
833
  );
738
834
  }
739
835
 
836
+ const SelectInputOptionContentWithinTriggerContext = createContext(false);
837
+
740
838
  export interface SelectInputOptionContentProps {
741
839
  title: string;
742
840
  note?: string;
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);
@@ -0,0 +1,9 @@
1
+ import { defineMessages } from 'react-intl';
2
+
3
+ export default defineMessages({
4
+ clearLabel: {
5
+ id: 'neptune.ClearButton.ariaLabel',
6
+ defaultMessage: 'Clear',
7
+ description: 'Description of clear button',
8
+ },
9
+ });
@@ -1,14 +1,26 @@
1
1
  import { Field } from '../field/Field';
2
2
  import { mockMatchMedia, render, screen } from '../test-utils';
3
3
  import Typeahead from './Typeahead';
4
+ import { createIntl, createIntlCache } from 'react-intl';
5
+ import messages from '../i18n';
6
+ import { DEFAULT_LANG, DEFAULT_LOCALE } from '../common';
4
7
 
5
8
  mockMatchMedia();
6
9
 
10
+ const cache = createIntlCache();
11
+ const intl = createIntl({ locale: DEFAULT_LOCALE, messages: messages[DEFAULT_LANG] }, cache);
12
+
7
13
  describe('Typeahead', () => {
8
14
  it('supports `Field` for labeling', () => {
9
15
  render(
10
16
  <Field id="test" label="Tags">
11
- <Typeahead id="test" name="test" options={[{ label: 'Test' }]} onChange={() => {}} />
17
+ <Typeahead
18
+ id="test"
19
+ name="test"
20
+ options={[{ label: 'Test' }]}
21
+ intl={intl}
22
+ onChange={() => {}}
23
+ />
12
24
  </Field>,
13
25
  );
14
26
  expect(screen.getAllByRole('group')[0]).toHaveAccessibleName(/^Tags/);
@@ -8,15 +8,17 @@ import { fakeEvent, fakeKeyDownEventForKey } from '../common/fakeEvents';
8
8
  import Typeahead from './Typeahead';
9
9
 
10
10
  const defaultLocale = 'en-GB';
11
-
12
- jest.mock('react-intl', () => ({
13
- injectIntl: (Component) =>
14
- function (props) {
15
- return <Component {...props} intl={{ locale: defaultLocale }} />;
16
- },
17
- useIntl: () => ({ locale: defaultLocale, formatMessage: (id) => `${id}` }),
18
- defineMessages: (translations) => translations,
19
- }));
11
+ jest.mock('react-intl', () => {
12
+ const mockedIntl = {
13
+ locale: defaultLocale,
14
+ formatMessage: (id) => String(id),
15
+ };
16
+ return {
17
+ injectIntl: (Component) => (props) => <Component {...props} intl={mockedIntl} />,
18
+ defineMessages: (translations) => translations,
19
+ useIntl: () => mockedIntl,
20
+ };
21
+ });
20
22
 
21
23
  describe('Typeahead', () => {
22
24
  let component;
@@ -317,7 +319,7 @@ describe('Typeahead', () => {
317
319
  onChange: (selections) => {
318
320
  selectedOption = selections[0];
319
321
  },
320
- options: options,
322
+ options,
321
323
  });
322
324
 
323
325
  input().simulate('change', { target: { value: text } });