@transferwise/components 46.121.2 → 46.122.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.
@@ -93,8 +93,13 @@ export type SelectInputItem<T = string> =
93
93
  function dedupeSelectInputOptionItem<T>(
94
94
  item: SelectInputOptionItem<T>,
95
95
  existingValues: Set<T>,
96
+ compareValues?: (a: T, b: T) => boolean,
96
97
  ): SelectInputOptionItem<T | undefined> {
97
- if (!existingValues.has(item.value)) {
98
+ const isDuplicate = compareValues
99
+ ? Array.from(existingValues).some((existingValue) => compareValues(item.value, existingValue))
100
+ : existingValues.has(item.value);
101
+
102
+ if (!isDuplicate) {
98
103
  existingValues.add(item.value);
99
104
  return item;
100
105
  }
@@ -108,18 +113,20 @@ function dedupeSelectInputOptionItem<T>(
108
113
  */
109
114
  function dedupeSelectInputItems<T>(
110
115
  items: readonly SelectInputItem<T>[],
116
+ compareValues?: (a: T, b: T) => boolean,
111
117
  ): SelectInputItem<T | undefined>[] {
112
118
  const existingValues = new Set<T>();
119
+
113
120
  return items.map((item) => {
114
121
  switch (item.type) {
115
122
  case 'option': {
116
- return dedupeSelectInputOptionItem(item, existingValues);
123
+ return dedupeSelectInputOptionItem(item, existingValues, compareValues);
117
124
  }
118
125
  case 'group': {
119
126
  return {
120
127
  ...item,
121
128
  options: item.options.map((option) =>
122
- dedupeSelectInputOptionItem(option, existingValues),
129
+ dedupeSelectInputOptionItem(option, existingValues, compareValues),
123
130
  ),
124
131
  };
125
132
  }
@@ -481,6 +488,7 @@ export function SelectInput<T = string, M extends boolean = false>({
481
488
  id={id ? `${id}Search` : undefined}
482
489
  parentId={parentId}
483
490
  items={items}
491
+ compareValues={compareValues}
484
492
  renderValue={renderValue}
485
493
  renderFooter={renderFooter}
486
494
  filterable={filterable}
@@ -589,7 +597,14 @@ const SelectInputOptionsContainer = forwardRef(function SelectInputOptionsContai
589
597
 
590
598
  interface SelectInputOptionsProps<T = string> extends Pick<
591
599
  SelectInputProps<T>,
592
- 'items' | 'renderValue' | 'renderFooter' | 'filterable' | 'filterPlaceholder' | 'id' | 'parentId'
600
+ | 'items'
601
+ | 'renderValue'
602
+ | 'renderFooter'
603
+ | 'filterable'
604
+ | 'filterPlaceholder'
605
+ | 'id'
606
+ | 'parentId'
607
+ | 'compareValues'
593
608
  > {
594
609
  searchInputRef: React.MutableRefObject<HTMLInputElement | null>;
595
610
  listboxRef: React.MutableRefObject<HTMLDivElement | null>;
@@ -606,6 +621,7 @@ function SelectInputOptions<T = string>({
606
621
  id,
607
622
  parentId,
608
623
  items,
624
+ compareValues: compareValuesProp,
609
625
  renderValue = String,
610
626
  renderFooter,
611
627
  filterable = false,
@@ -651,9 +667,27 @@ function SelectInputOptions<T = string>({
651
667
  }
652
668
  }, [controllerRef, needle]);
653
669
 
670
+ const compareValues = useMemo(() => {
671
+ if (!compareValuesProp) {
672
+ return undefined;
673
+ }
674
+
675
+ if (typeof compareValuesProp === 'function') {
676
+ return (a: NonNullable<T>, b: NonNullable<T>) => compareValuesProp(a, b);
677
+ }
678
+
679
+ const key = compareValuesProp;
680
+ return (a: NonNullable<T>, b: NonNullable<T>) => {
681
+ if (typeof a === 'object' && a != null && typeof b === 'object' && b != null) {
682
+ return (a as Record<string, unknown>)[key] === (b as Record<string, unknown>)[key];
683
+ }
684
+ return a === b;
685
+ };
686
+ }, [compareValuesProp]);
687
+
654
688
  const filteredItems: readonly SelectInputItem<NonNullable<T> | undefined>[] =
655
689
  needle != null
656
- ? filterSelectInputItems(dedupeSelectInputItems(items), (item) =>
690
+ ? filterSelectInputItems(dedupeSelectInputItems(items, compareValues), (item) =>
657
691
  selectInputOptionItemIncludesNeedle(item, needle),
658
692
  )
659
693
  : items;
@@ -23,12 +23,12 @@ describe('InlinePrompt', () => {
23
23
  });
24
24
 
25
25
  [
26
- { sentiment: Sentiment.NEGATIVE as const, acceptsMedia: false, statusIconLabel: 'Error:' },
27
- { sentiment: Sentiment.WARNING as const, acceptsMedia: false, statusIconLabel: 'Warning:' },
28
- { sentiment: Sentiment.NEUTRAL as const, acceptsMedia: false, statusIconLabel: 'Information:' },
29
- { sentiment: Sentiment.POSITIVE as const, acceptsMedia: true, statusIconLabel: 'Success:' },
30
- { sentiment: 'proposition' as const, acceptsMedia: true, statusIconLabel: '' },
31
- ].forEach(({ sentiment, statusIconLabel, acceptsMedia }) => {
26
+ { sentiment: Sentiment.NEGATIVE as const, statusIconLabel: 'Error:' },
27
+ { sentiment: Sentiment.WARNING as const, statusIconLabel: 'Warning:' },
28
+ { sentiment: Sentiment.NEUTRAL as const, statusIconLabel: 'Information:' },
29
+ { sentiment: Sentiment.POSITIVE as const, statusIconLabel: 'Success:' },
30
+ { sentiment: 'proposition' as const, statusIconLabel: '' },
31
+ ].forEach(({ sentiment, statusIconLabel }) => {
32
32
  describe(sentiment, () => {
33
33
  it('should apply correct styles', () => {
34
34
  render(<InlinePrompt {...defaultProps} sentiment={sentiment} />);
@@ -77,18 +77,10 @@ describe('InlinePrompt', () => {
77
77
  });
78
78
 
79
79
  describe('custom media', () => {
80
- if (acceptsMedia) {
81
- it('should respect `media`', () => {
82
- render(<InlinePrompt {...defaultProps} sentiment={sentiment} media={MEDIA} />);
83
- expect(screen.getByTestId('custom-media')).toBeInTheDocument();
84
- });
85
- } else {
86
- it('should ignore `media`', () => {
87
- render(<InlinePrompt {...defaultProps} sentiment={sentiment} media={MEDIA} />);
88
- expect(screen.getByLabelText(statusIconLabel)).toBeInTheDocument();
89
- expect(screen.queryByTestId('custom-media')).not.toBeInTheDocument();
90
- });
91
- }
80
+ it('should respect `media`', () => {
81
+ render(<InlinePrompt {...defaultProps} sentiment={sentiment} media={MEDIA} />);
82
+ expect(screen.getByTestId('custom-media')).toBeInTheDocument();
83
+ });
92
84
  });
93
85
 
94
86
  describe('mediaLabel', () => {
@@ -1,7 +1,7 @@
1
1
  import type { ReactElement } from 'react';
2
2
  import type { Meta, StoryObj } from '@storybook/react-webpack5';
3
3
  import { action } from 'storybook/actions';
4
- import { Taxi, Travel } from '@transferwise/icons';
4
+ import { Clock, Id, Taxi, Travel } from '@transferwise/icons';
5
5
  import { lorem5 } from '../../test-utils';
6
6
  import Link from '../../link';
7
7
  import { InlinePrompt, InlinePromptProps } from './InlinePrompt';
@@ -222,8 +222,8 @@ export const Muted: StoryObj<PreviewStoryArgs> = {
222
222
  };
223
223
 
224
224
  /**
225
- * While main sentiments (`warning`, `negative`, `neutral`) must retain their associated
226
- * `StatusIcons`, the `positive` and `proposition` ones allow for Icon overrides to bring it
225
+ * While all main sentiments (`warning`, `negative`, `positive` and`neutral`) are associated with a
226
+ * default `StatusIcon`s, we also allow for Icon overrides to bring the prompt's visual language
227
227
  * closer to the prompt's content. <br /><br />
228
228
  * It's also possible to override the default icon's accessible name announced by screen readers
229
229
  * via `mediaLabel` prop, which is especially useful for the `proposition` sentiment.
@@ -232,14 +232,17 @@ export const IconOverrides: StoryObj<PreviewStoryArgs> = {
232
232
  render: (args: PreviewStoryArgs) => {
233
233
  return (
234
234
  <>
235
- <InlinePrompt {...args} media={<Travel />} sentiment="positive">
235
+ <InlinePrompt {...args} media={<Travel />} sentiment="positive" mediaLabel="Success: ">
236
236
  Your travel account is set up and ready to use.
237
237
  </InlinePrompt>
238
238
  <InlinePrompt {...args} media={<Taxi />} sentiment="proposition" mediaLabel="Taxi addon: ">
239
239
  Connect Wise with your taxi app to get exclusive discounts.
240
240
  </InlinePrompt>
241
- <InlinePrompt {...args} media={<Taxi />} sentiment="negative">
242
- This icon override is ignored.
241
+ <InlinePrompt {...args} media={<Clock />} sentiment="warning" mediaLabel="Processing: ">
242
+ The account verification is taking longer than usual.
243
+ </InlinePrompt>
244
+ <InlinePrompt {...args} media={<Id />} sentiment="negative" mediaLabel="Error: ">
245
+ The identity document you provided has expired.
243
246
  </InlinePrompt>
244
247
  </>
245
248
  );
@@ -25,12 +25,12 @@ export type InlinePromptProps = {
25
25
  */
26
26
  muted?: boolean;
27
27
  /**
28
- * Icon override for `proposition` and `positive` sentiments. Unsupported for remaining ones.
28
+ * Icon override for all sentiments. If the sentiment uses StatusIcon by default, it will be
29
+ * replaced by a plain icon.
29
30
  */
30
31
  media?: React.ReactNode;
31
32
  /**
32
33
  * Override for the sentiment's-derived, default, accessible name announced by the screen readers.
33
- * To be used primarily for `proposition` sentiment.
34
34
  */
35
35
  mediaLabel?: string;
36
36
  /**
@@ -68,6 +68,7 @@ export const InlinePrompt = ({
68
68
  if (muted) {
69
69
  return <BackslashCircle size={16} data-testid="InlinePrompt_Muted" title={mediaLabel} />;
70
70
  }
71
+
71
72
  if (loading) {
72
73
  return (
73
74
  <ProcessIndicator
@@ -78,15 +79,11 @@ export const InlinePrompt = ({
78
79
  );
79
80
  }
80
81
 
81
- if (sentiment === 'positive' && media) {
82
- return media;
83
- }
84
-
85
82
  if (sentiment === 'proposition') {
86
83
  return media || <GiftBox title={mediaLabel} />;
87
84
  }
88
85
 
89
- return <StatusIcon size={16} sentiment={sentiment} iconLabel={mediaLabel} />;
86
+ return media || <StatusIcon size={16} sentiment={sentiment} iconLabel={mediaLabel} />;
90
87
  };
91
88
 
92
89
  return (