@transferwise/components 46.30.2 → 46.32.0

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 (80) hide show
  1. package/build/index.js +931 -523
  2. package/build/index.js.map +1 -1
  3. package/build/index.mjs +928 -523
  4. package/build/index.mjs.map +1 -1
  5. package/build/main.css +135 -0
  6. package/build/styles/carousel/Carousel.css +135 -0
  7. package/build/styles/main.css +135 -0
  8. package/build/types/carousel/Carousel.d.ts +26 -0
  9. package/build/types/carousel/Carousel.d.ts.map +1 -0
  10. package/build/types/carousel/index.d.ts +3 -0
  11. package/build/types/carousel/index.d.ts.map +1 -0
  12. package/build/types/common/card/Card.d.ts +2 -2
  13. package/build/types/common/card/Card.d.ts.map +1 -1
  14. package/build/types/dateInput/DateInput.d.ts +5 -4
  15. package/build/types/dateInput/DateInput.d.ts.map +1 -1
  16. package/build/types/dateLookup/DateLookup.d.ts +11 -4
  17. package/build/types/dateLookup/DateLookup.d.ts.map +1 -1
  18. package/build/types/field/Field.d.ts +12 -0
  19. package/build/types/field/Field.d.ts.map +1 -0
  20. package/build/types/index.d.ts +6 -0
  21. package/build/types/index.d.ts.map +1 -1
  22. package/build/types/inputs/Input.d.ts.map +1 -1
  23. package/build/types/inputs/SelectInput.d.ts +1 -1
  24. package/build/types/inputs/SelectInput.d.ts.map +1 -1
  25. package/build/types/inputs/TextArea.d.ts.map +1 -1
  26. package/build/types/inputs/_common.d.ts +2 -2
  27. package/build/types/inputs/_common.d.ts.map +1 -1
  28. package/build/types/inputs/contexts.d.ts +24 -0
  29. package/build/types/inputs/contexts.d.ts.map +1 -0
  30. package/build/types/label/Label.d.ts +9 -0
  31. package/build/types/label/Label.d.ts.map +1 -0
  32. package/build/types/phoneNumberInput/PhoneNumberInput.d.ts +1 -1
  33. package/build/types/phoneNumberInput/PhoneNumberInput.d.ts.map +1 -1
  34. package/build/types/promoCard/PromoCard.d.ts +16 -5
  35. package/build/types/promoCard/PromoCard.d.ts.map +1 -1
  36. package/build/types/radioGroup/RadioGroup.d.ts.map +1 -1
  37. package/build/types/switch/Switch.d.ts +6 -3
  38. package/build/types/switch/Switch.d.ts.map +1 -1
  39. package/package.json +3 -3
  40. package/src/carousel/Carousel.css +135 -0
  41. package/src/carousel/Carousel.less +133 -0
  42. package/src/carousel/Carousel.spec.tsx +221 -0
  43. package/src/carousel/Carousel.story.tsx +63 -0
  44. package/src/carousel/Carousel.tsx +345 -0
  45. package/src/carousel/index.ts +3 -0
  46. package/src/common/card/Card.tsx +51 -43
  47. package/src/dateInput/DateInput.rtl.spec.tsx +17 -0
  48. package/src/dateInput/DateInput.tsx +28 -22
  49. package/src/dateLookup/DateLookup.keyboardEvents.spec.js +2 -2
  50. package/src/dateLookup/DateLookup.rtl.spec.tsx +21 -0
  51. package/src/dateLookup/DateLookup.state.spec.js +5 -5
  52. package/src/dateLookup/DateLookup.tests.story.tsx +4 -11
  53. package/src/dateLookup/DateLookup.tsx +24 -9
  54. package/src/dateLookup/DateLookup.view.spec.js +11 -11
  55. package/src/field/Field.spec.tsx +95 -0
  56. package/src/field/Field.story.tsx +59 -0
  57. package/src/field/Field.tsx +70 -0
  58. package/src/index.ts +6 -0
  59. package/src/inputs/Input.tsx +5 -3
  60. package/src/inputs/SelectInput.spec.tsx +10 -0
  61. package/src/inputs/SelectInput.tsx +9 -4
  62. package/src/inputs/TextArea.tsx +6 -3
  63. package/src/inputs/_ButtonInput.tsx +2 -2
  64. package/src/inputs/_common.ts +2 -2
  65. package/src/inputs/contexts.tsx +45 -0
  66. package/src/label/Label.spec.tsx +26 -0
  67. package/src/label/Label.story.tsx +37 -0
  68. package/src/label/Label.tsx +20 -0
  69. package/src/main.css +135 -0
  70. package/src/main.less +1 -0
  71. package/src/phoneNumberInput/PhoneNumberInput.story.tsx +16 -22
  72. package/src/phoneNumberInput/PhoneNumberInput.tsx +14 -2
  73. package/src/promoCard/PromoCard.story.tsx +2 -2
  74. package/src/promoCard/PromoCard.tsx +30 -9
  75. package/src/radioGroup/RadioGroup.rtl.spec.tsx +14 -0
  76. package/src/radioGroup/RadioGroup.story.tsx +26 -0
  77. package/src/radioGroup/RadioGroup.tsx +4 -1
  78. package/src/switch/Switch.spec.tsx +10 -0
  79. package/src/switch/Switch.tsx +22 -13
  80. package/src/utilities/logActionRequired.js +1 -1
@@ -1,5 +1,5 @@
1
1
  import classNames from 'classnames';
2
- import { MouseEvent, ReactNode, useRef } from 'react';
2
+ import { MouseEvent, type ReactNode, forwardRef, useRef } from 'react';
3
3
 
4
4
  import { CloseButton } from '../closeButton';
5
5
  import { stopPropagation } from '../domHelpers';
@@ -48,48 +48,56 @@ export interface CardProps {
48
48
  * <p>Hello World!</p>
49
49
  * </Card>
50
50
  */
51
- const Card: React.FC<CardProps> = ({
52
- className,
53
- children = null,
54
- id,
55
- isDisabled = false,
56
- isSmall = false,
57
- onDismiss,
58
- testId,
59
- ...props
60
- }) => {
61
- const closeButtonReference = useRef(null);
51
+ const Card = forwardRef<HTMLDivElement, CardProps>(
52
+ (
53
+ {
54
+ className,
55
+ children = null,
56
+ id,
57
+ isDisabled = false,
58
+ isSmall = false,
59
+ onDismiss,
60
+ testId,
61
+ ...props
62
+ },
63
+ ref,
64
+ ) => {
65
+ const closeButtonReference = useRef(null);
62
66
 
63
- return (
64
- <div
65
- className={classNames(
66
- 'np-Card',
67
- {
68
- 'np-Card--small': !!isSmall,
69
- 'is-disabled': !!isDisabled,
70
- },
71
- className,
72
- )}
73
- id={id}
74
- data-testid={testId}
75
- {...props}
76
- >
77
- {onDismiss && (
78
- <CloseButton
79
- ref={closeButtonReference}
80
- className="np-Card-closeButton"
81
- size={isSmall ? 'sm' : 'md'}
82
- isDisabled={isDisabled}
83
- testId="close-button"
84
- onClick={(e) => {
85
- stopPropagation(e);
86
- onDismiss();
87
- }}
88
- />
89
- )}
90
- {children}
91
- </div>
92
- );
93
- };
67
+ return (
68
+ <div
69
+ ref={ref}
70
+ className={classNames(
71
+ 'np-Card',
72
+ {
73
+ 'np-Card--small': !!isSmall,
74
+ 'is-disabled': !!isDisabled,
75
+ },
76
+ className,
77
+ )}
78
+ id={id}
79
+ data-testid={testId}
80
+ {...props}
81
+ >
82
+ {onDismiss && (
83
+ <CloseButton
84
+ ref={closeButtonReference}
85
+ className="np-Card-closeButton"
86
+ size={isSmall ? 'sm' : 'md'}
87
+ isDisabled={isDisabled}
88
+ testId="close-button"
89
+ onClick={(e) => {
90
+ stopPropagation(e);
91
+ onDismiss();
92
+ }}
93
+ />
94
+ )}
95
+ {children}
96
+ </div>
97
+ );
98
+ },
99
+ );
100
+
101
+ Card.displayName = 'Card';
94
102
 
95
103
  export default Card;
@@ -0,0 +1,17 @@
1
+ import { Field } from '../field/Field';
2
+ import { mockMatchMedia, mockResizeObserver, render, screen } from '../test-utils';
3
+ import DateInput from './DateInput';
4
+
5
+ mockMatchMedia();
6
+ mockResizeObserver();
7
+
8
+ describe('DateInput', () => {
9
+ it('supports `Field` for labeling', () => {
10
+ render(
11
+ <Field label="Date of birth">
12
+ <DateInput onChange={() => {}} />
13
+ </Field>,
14
+ );
15
+ expect(screen.getAllByRole('group')[0]).toHaveAccessibleName(/^Date of birth/);
16
+ });
17
+ });
@@ -2,22 +2,23 @@ import classNames from 'classnames';
2
2
  import { useState } from 'react';
3
3
  import { useIntl } from 'react-intl';
4
4
 
5
- import { Input, SelectInput, SelectInputProps, SelectInputOptionContent } from '..';
6
- import { Size, DateMode, MonthFormat, SizeSmall, SizeMedium, SizeLarge } from '../common';
7
- import { getMonthNames, isDateValid, isMonthAndYearFormat, MDY, YMD } from '../common/dateUtils';
8
-
5
+ import { Input, SelectInput, SelectInputOptionContent, SelectInputProps } from '..';
6
+ import { DateMode, MonthFormat, Size, SizeLarge, SizeMedium, SizeSmall } from '../common';
7
+ import { MDY, YMD, getMonthNames, isDateValid, isMonthAndYearFormat } from '../common/dateUtils';
8
+ import { useInputAttributes } from '../inputs/contexts';
9
9
  import messages from './DateInput.messages';
10
10
  import { convertToLocalMidnight } from './utils';
11
11
 
12
12
  export interface DateInputProps {
13
+ /** @deprecated Use `Field` wrapper or the `aria-labelledby` attribute instead. */
13
14
  'aria-label'?: string;
14
15
  'aria-labelledby'?: string;
15
16
  disabled?: boolean;
16
17
  size?: SizeSmall | SizeMedium | SizeLarge;
17
18
  value?: Date | string;
18
19
  onChange: (value: string | null) => void;
19
- onFocus?: React.FocusEventHandler<HTMLInputElement>;
20
- onBlur?: React.FocusEventHandler<HTMLInputElement>;
20
+ onFocus?: React.FocusEventHandler<HTMLDivElement>;
21
+ onBlur?: React.FocusEventHandler<HTMLDivElement>;
21
22
  dayLabel?: string;
22
23
  dayAutoComplete?: string;
23
24
  monthLabel?: string;
@@ -35,7 +36,7 @@ export interface DateInputProps {
35
36
  }
36
37
 
37
38
  const DateInput = ({
38
- 'aria-labelledby': ariaLabelledBy,
39
+ 'aria-labelledby': ariaLabelledByProp,
39
40
  'aria-label': ariaLabel,
40
41
  disabled = false,
41
42
  size = Size.MEDIUM,
@@ -51,9 +52,13 @@ const DateInput = ({
51
52
  onFocus,
52
53
  onBlur,
53
54
  placeholders,
54
- id,
55
+ id: idProp,
55
56
  selectProps = {},
56
57
  }: DateInputProps) => {
58
+ const inputAttributes = useInputAttributes({ nonLabelable: true });
59
+ const id = idProp ?? inputAttributes.id;
60
+ const ariaLabelledBy = ariaLabelledByProp ?? inputAttributes['aria-labelledby'];
61
+
57
62
  const { locale, formatMessage } = useIntl();
58
63
 
59
64
  const getDateObject = (): Date | undefined => {
@@ -95,9 +100,9 @@ const DateInput = ({
95
100
  );
96
101
  const monthNames = getMonthNames(locale, monthFormat);
97
102
 
98
- dayLabel = dayLabel || formatMessage(messages.dayLabel);
99
- monthLabel = monthLabel || formatMessage(messages.monthLabel);
100
- yearLabel = yearLabel || formatMessage(messages.yearLabel);
103
+ dayLabel ||= formatMessage(messages.dayLabel);
104
+ monthLabel ||= formatMessage(messages.monthLabel);
105
+ yearLabel ||= formatMessage(messages.yearLabel);
101
106
  placeholders = {
102
107
  day: placeholders?.day || formatMessage(messages.dayPlaceholder),
103
108
  month: placeholders?.month || formatMessage(messages.monthLabel),
@@ -298,14 +303,15 @@ const DateInput = ({
298
303
  return (
299
304
  <div
300
305
  className="tw-date"
306
+ {...inputAttributes}
301
307
  id={id}
302
308
  aria-labelledby={ariaLabelledBy}
303
309
  aria-label={ariaLabel}
304
310
  role="group" // Add role attribute to indicate container for interactive elements
305
- onFocus={(event: React.FocusEvent<HTMLInputElement>) =>
311
+ onFocus={(event) =>
306
312
  shouldPropagateOnFocus(event) ? onFocus && onFocus(event) : event.stopPropagation()
307
313
  }
308
- onBlur={(event: React.FocusEvent<HTMLInputElement>) =>
314
+ onBlur={(event) =>
309
315
  shouldPropagateOnBlur(event) ? onBlur && onBlur(event) : event.stopPropagation()
310
316
  }
311
317
  >
@@ -328,7 +334,8 @@ const DateInput = ({
328
334
  {getYear()}
329
335
  </>
330
336
  );
331
- } else if (yearFirst) {
337
+ }
338
+ if (yearFirst) {
332
339
  return (
333
340
  <>
334
341
  {getYear()}
@@ -336,15 +343,14 @@ const DateInput = ({
336
343
  {getDay()}
337
344
  </>
338
345
  );
339
- } else {
340
- return (
341
- <>
342
- {getDay()}
343
- {getMonth()}
344
- {getYear()}
345
- </>
346
- );
347
346
  }
347
+ return (
348
+ <>
349
+ {getDay()}
350
+ {getMonth()}
351
+ {getYear()}
352
+ </>
353
+ );
348
354
  })()}
349
355
  </div>
350
356
  </div>
@@ -4,12 +4,12 @@ import { mount } from 'enzyme';
4
4
  import { fakeKeyDownEventForKey } from '../common/fakeEvents';
5
5
  import { mockMatchMedia } from '../test-utils';
6
6
 
7
- import DateLookup from '.';
7
+ import { DateLookupWithoutInputAttributes as DateLookup } from './DateLookup';
8
8
 
9
9
  mockMatchMedia();
10
10
 
11
11
  const defaultLocale = 'en-GB';
12
- const formatMessage = (id) => `${id}`;
12
+ const formatMessage = (id) => String(id);
13
13
  jest.mock('react-intl', () => ({
14
14
  injectIntl: (Component) =>
15
15
  function (props) {
@@ -0,0 +1,21 @@
1
+ import { Field } from '../field/Field';
2
+ import { mockMatchMedia, mockResizeObserver, render, screen } from '../test-utils';
3
+ import DateLookup from './DateLookup';
4
+
5
+ mockMatchMedia();
6
+ mockResizeObserver();
7
+
8
+ const now = new Date();
9
+
10
+ describe('DateLookup', () => {
11
+ it('supports `Field` for labeling', () => {
12
+ render(
13
+ <Field label="Date of birth">
14
+ <DateLookup value={now} onChange={() => {}} />
15
+ </Field>,
16
+ );
17
+ expect(screen.getByLabelText('Date of birth')).toHaveTextContent(
18
+ now.getUTCFullYear().toString(),
19
+ );
20
+ });
21
+ });
@@ -1,7 +1,7 @@
1
1
  import { shallow } from 'enzyme';
2
2
  import { useIntl } from 'react-intl';
3
3
 
4
- import DateLookup from '.';
4
+ import { DateLookupWithoutInputAttributes as DateLookup } from './DateLookup';
5
5
 
6
6
  jest.mock('react-intl');
7
7
 
@@ -38,7 +38,7 @@ describe('DateLookup state', () => {
38
38
  it('updates selectedDate on props value change', () => {
39
39
  const props = { value: new Date(2018, 11, 27) };
40
40
  const newState = DateLookup.getDerivedStateFromProps(props, defaultState);
41
- expect(+newState.selectedDate).toBe(+new Date(2018, 11, 27));
41
+ expect(Number(newState.selectedDate)).toBe(Number(new Date(2018, 11, 27)));
42
42
  });
43
43
 
44
44
  it('sets date values to midnight', () => {
@@ -48,9 +48,9 @@ describe('DateLookup state', () => {
48
48
  max: new Date(2018, 11, 28, 0, 30),
49
49
  };
50
50
  const newState = DateLookup.getDerivedStateFromProps(props, defaultState);
51
- expect(+newState.selectedDate).toBe(+new Date(2018, 11, 27, 0, 0));
52
- expect(+newState.min).toBe(+new Date(2018, 10, 27, 0, 0));
53
- expect(+newState.max).toBe(+new Date(2018, 11, 28, 0, 0));
51
+ expect(Number(newState.selectedDate)).toBe(Number(new Date(2018, 11, 27, 0, 0)));
52
+ expect(Number(newState.min)).toBe(Number(new Date(2018, 10, 27, 0, 0)));
53
+ expect(Number(newState.max)).toBe(Number(new Date(2018, 11, 28, 0, 0)));
54
54
  });
55
55
 
56
56
  it('calls onChange with min when it is < min', () => {
@@ -1,6 +1,5 @@
1
- import { ReactRenderer, Story } from '@storybook/react';
1
+ import { Story } from '@storybook/react';
2
2
  import { expect, userEvent, within } from '@storybook/test';
3
- import { PlayFunctionContext } from '@storybook/types';
4
3
  import { useState } from 'react';
5
4
 
6
5
  import DateLookup from './DateLookup';
@@ -10,7 +9,7 @@ export default {
10
9
  title: 'Forms/DateLookup/Tests',
11
10
  };
12
11
 
13
- const Template: Story<DateLookup> = () => {
12
+ const Template: Story<typeof DateLookup> = () => {
14
13
  const [value, setValue] = useState<Date | null>(new Date(1987, 0, 10, 12, 0, 0));
15
14
 
16
15
  return (
@@ -27,10 +26,7 @@ const Template: Story<DateLookup> = () => {
27
26
 
28
27
  export const ClearSpace = Template.bind({});
29
28
 
30
- ClearSpace.play = async ({
31
- canvasElement,
32
- step,
33
- }: PlayFunctionContext<ReactRenderer, DateLookup>) => {
29
+ ClearSpace.play = async ({ canvasElement, step }) => {
34
30
  const canvas = within(canvasElement);
35
31
 
36
32
  await step('space can activate clear button', async () => {
@@ -49,10 +45,7 @@ ClearSpace.play = async ({
49
45
 
50
46
  export const ClearEnter = Template.bind({});
51
47
 
52
- ClearEnter.play = async ({
53
- canvasElement,
54
- step,
55
- }: PlayFunctionContext<ReactRenderer, DateLookup>) => {
48
+ ClearEnter.play = async ({ canvasElement, step }) => {
56
49
  const canvas = within(canvasElement);
57
50
 
58
51
  await step('enter can activate clear button', async () => {
@@ -12,7 +12,7 @@ import {
12
12
  } from '../common';
13
13
  import { isWithinRange, moveToWithinRange } from '../common/dateUtils';
14
14
  import ResponsivePanel from '../common/responsivePanel';
15
-
15
+ import { WithInputAttributesProps, withInputAttributes } from '../inputs/contexts';
16
16
  import DateTrigger from './dateTrigger';
17
17
  import DayCalendar from './dayCalendar';
18
18
  import { getStartOfDay } from './getStartOfDay';
@@ -36,6 +36,8 @@ export interface DateLookupProps {
36
36
  onBlur?: () => void;
37
37
  }
38
38
 
39
+ type DateLookupPropsWithInputAttributes = DateLookupProps & Partial<WithInputAttributesProps>;
40
+
39
41
  interface DateLookupState {
40
42
  selectedDate: Date | null;
41
43
  originalDate: Date | null;
@@ -48,9 +50,9 @@ interface DateLookupState {
48
50
  isMobile: boolean;
49
51
  }
50
52
 
51
- class DateLookup extends PureComponent<DateLookupProps, DateLookupState> {
52
- declare props: DateLookupProps &
53
- Required<Pick<DateLookupProps, keyof typeof DateLookup.defaultProps>>;
53
+ class DateLookup extends PureComponent<DateLookupPropsWithInputAttributes, DateLookupState> {
54
+ declare props: DateLookupPropsWithInputAttributes &
55
+ Required<Pick<DateLookupPropsWithInputAttributes, keyof typeof DateLookup.defaultProps>>;
54
56
 
55
57
  static defaultProps = {
56
58
  value: null,
@@ -62,7 +64,7 @@ class DateLookup extends PureComponent<DateLookupProps, DateLookupState> {
62
64
  monthFormat: MonthFormat.LONG,
63
65
  disabled: false,
64
66
  clearable: false,
65
- } satisfies Partial<DateLookupProps>;
67
+ } satisfies Partial<DateLookupPropsWithInputAttributes>;
66
68
 
67
69
  element = createRef<HTMLDivElement>();
68
70
  dropdown = createRef<HTMLDivElement>();
@@ -106,7 +108,7 @@ class DateLookup extends PureComponent<DateLookupProps, DateLookupState> {
106
108
  return null;
107
109
  }
108
110
 
109
- componentDidUpdate(previousProps: DateLookupProps) {
111
+ componentDidUpdate(previousProps: DateLookupPropsWithInputAttributes) {
110
112
  if (this.props.value?.getTime() !== previousProps.value?.getTime() && this.state.open) {
111
113
  this.focusOn('.active');
112
114
  }
@@ -301,19 +303,25 @@ class DateLookup extends PureComponent<DateLookupProps, DateLookupState> {
301
303
  const { selectedDate, open } = this.state;
302
304
 
303
305
  const {
306
+ inputAttributes,
307
+ id: idProp,
308
+ 'aria-labelledby': ariaLabelledByProp,
304
309
  size,
305
310
  placeholder,
306
311
  label,
307
- 'aria-labelledby': ariaLabelledBy,
308
312
  monthFormat,
309
313
  disabled,
310
314
  clearable,
311
315
  value,
312
316
  } = this.props;
317
+ const id = idProp ?? inputAttributes?.id;
318
+ const ariaLabelledBy = ariaLabelledByProp ?? inputAttributes?.['aria-labelledby'];
319
+
313
320
  return (
314
321
  <div
315
322
  ref={this.element}
316
- id={this.props.id}
323
+ {...inputAttributes}
324
+ id={id}
317
325
  aria-labelledby={ariaLabelledBy}
318
326
  className="input-group"
319
327
  onKeyDown={this.handleKeyDown}
@@ -342,4 +350,11 @@ class DateLookup extends PureComponent<DateLookupProps, DateLookupState> {
342
350
  }
343
351
  }
344
352
 
345
- export default DateLookup;
353
+ export const DateLookupWithoutInputAttributes = DateLookup;
354
+
355
+ export default withInputAttributes(
356
+ DateLookup as React.ComponentType<DateLookupPropsWithInputAttributes>,
357
+ {
358
+ nonLabelable: true,
359
+ },
360
+ );
@@ -7,7 +7,7 @@ import DayCalendar from './dayCalendar';
7
7
  import MonthCalendar from './monthCalendar';
8
8
  import YearCalendar from './yearCalendar';
9
9
 
10
- import DateLookup from '.';
10
+ import { DateLookupWithoutInputAttributes as DateLookup } from './DateLookup';
11
11
 
12
12
  mockMatchMedia();
13
13
 
@@ -51,7 +51,7 @@ describe('DateLookup view', () => {
51
51
  });
52
52
 
53
53
  it('passes props forward to open button', () => {
54
- expect(+dateTrigger().prop('selectedDate')).toBe(+date);
54
+ expect(Number(dateTrigger().prop('selectedDate'))).toBe(Number(date));
55
55
  expect(dateTrigger().prop('size')).toBe('lg');
56
56
  expect(dateTrigger().prop('placeholder')).toBe('Asd..');
57
57
  expect(dateTrigger().prop('label')).toBe('Date..');
@@ -76,9 +76,9 @@ describe('DateLookup view', () => {
76
76
  });
77
77
 
78
78
  it('passes props forward to day calendar', () => {
79
- expect(+dayCalendar().prop('selectedDate')).toBe(+date);
80
- expect(+dayCalendar().prop('min')).toBe(+min);
81
- expect(+dayCalendar().prop('max')).toBe(+max);
79
+ expect(Number(dayCalendar().prop('selectedDate'))).toBe(Number(date));
80
+ expect(Number(dayCalendar().prop('min'))).toBe(Number(min));
81
+ expect(Number(dayCalendar().prop('max'))).toBe(Number(max));
82
82
  expect(dayCalendar().prop('viewMonth')).toBe(11);
83
83
  expect(dayCalendar().prop('viewYear')).toBe(2018);
84
84
  expect(dayCalendar().prop('monthFormat')).toBe('long');
@@ -103,9 +103,9 @@ describe('DateLookup view', () => {
103
103
  });
104
104
 
105
105
  it('passes props forward to month calendar', () => {
106
- expect(+monthCalendar().prop('selectedDate')).toBe(+date);
107
- expect(+monthCalendar().prop('min')).toBe(+min);
108
- expect(+monthCalendar().prop('max')).toBe(+max);
106
+ expect(Number(monthCalendar().prop('selectedDate'))).toBe(Number(date));
107
+ expect(Number(monthCalendar().prop('min'))).toBe(Number(min));
108
+ expect(Number(monthCalendar().prop('max'))).toBe(Number(max));
109
109
  expect(monthCalendar().prop('viewYear')).toBe(2018);
110
110
  expect(monthCalendar().prop('placeholder')).toBe('Asd..');
111
111
  });
@@ -129,9 +129,9 @@ describe('DateLookup view', () => {
129
129
  });
130
130
 
131
131
  it('passes props forward to year calendar', () => {
132
- expect(+yearCalendar().prop('selectedDate')).toBe(+date);
133
- expect(+yearCalendar().prop('min')).toBe(+min);
134
- expect(+yearCalendar().prop('max')).toBe(+max);
132
+ expect(Number(yearCalendar().prop('selectedDate'))).toBe(Number(date));
133
+ expect(Number(yearCalendar().prop('min'))).toBe(Number(min));
134
+ expect(Number(yearCalendar().prop('max'))).toBe(Number(max));
135
135
  expect(yearCalendar().prop('viewYear')).toBe(2018);
136
136
  expect(yearCalendar().prop('placeholder')).toBe('Asd..');
137
137
  });
@@ -0,0 +1,95 @@
1
+ import Info from '../info/Info';
2
+ import { Input } from '../inputs/Input';
3
+ import { mockMatchMedia, render, screen, userEvent } from '../test-utils';
4
+
5
+ import { Field } from './Field';
6
+
7
+ mockMatchMedia();
8
+
9
+ describe('Field', () => {
10
+ it('should render label', () => {
11
+ render(
12
+ <Field label="Phone number">
13
+ <Input />
14
+ </Field>,
15
+ );
16
+
17
+ expect(screen.getByLabelText('Phone number')).toBeInTheDocument();
18
+ expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby');
19
+ });
20
+
21
+ it('should render help text if provided', () => {
22
+ render(
23
+ <Field label="Phone number" hint="This is help text">
24
+ <Input />
25
+ </Field>,
26
+ );
27
+
28
+ const textbox = screen.getByRole('textbox', { description: 'This is help text' });
29
+ expect(textbox).toBeInTheDocument();
30
+ expect(textbox).not.toBeInvalid();
31
+ });
32
+
33
+ it('should render error text if provided', () => {
34
+ render(
35
+ <Field label="Phone number" error="This is error text">
36
+ <Input />
37
+ </Field>,
38
+ );
39
+
40
+ const textbox = screen.getByRole('textbox', { description: 'This is error text' });
41
+ expect(textbox).toBeInTheDocument();
42
+ expect(textbox).toBeInvalid();
43
+ });
44
+
45
+ it('should prefer error text over help text if both are provided', () => {
46
+ render(
47
+ <Field label="Phone number" error="This is error text" hint="This is help text">
48
+ <Input />
49
+ </Field>,
50
+ );
51
+
52
+ expect(screen.getByRole('textbox', { description: 'This is error text' })).toBeInTheDocument();
53
+ expect(screen.queryByText('This is help text')).not.toBeInTheDocument();
54
+ });
55
+
56
+ it('avoids triggering button within label inadvertently', async () => {
57
+ const handleClick = jest.fn();
58
+
59
+ render(
60
+ <Field
61
+ label={
62
+ <span>
63
+ Phone number{' '}
64
+ <Info content="Further information explained in detail." onClick={handleClick} />
65
+ </span>
66
+ }
67
+ >
68
+ <Input />
69
+ </Field>,
70
+ );
71
+
72
+ const button = screen.getByRole('button');
73
+ button.addEventListener('click', handleClick);
74
+
75
+ const label = screen.getByText('Phone number');
76
+ userEvent.click(label);
77
+
78
+ expect(handleClick).not.toHaveBeenCalled();
79
+ });
80
+
81
+ it('allows nesting-based label association', async () => {
82
+ const handleClick = jest.fn();
83
+
84
+ render(
85
+ <Field id={null} label="Phone number">
86
+ <Input onClick={handleClick} />
87
+ </Field>,
88
+ );
89
+
90
+ expect(screen.getByRole('textbox')).not.toHaveAttribute('id');
91
+
92
+ const label = screen.getByText('Phone number');
93
+ userEvent.click(label);
94
+ });
95
+ });
@@ -0,0 +1,59 @@
1
+ import { useState } from 'react';
2
+
3
+ import { Input } from '../inputs/Input';
4
+ import { Field } from './Field';
5
+
6
+ export default {
7
+ component: Field,
8
+ title: 'Field',
9
+ };
10
+
11
+ export const Basic = () => {
12
+ const [value, setValue] = useState<string | undefined>('This is some text');
13
+ return (
14
+ <Field label="Phone number">
15
+ <Input value={value} onChange={({ target }) => setValue(target.value)} />
16
+ </Field>
17
+ );
18
+ };
19
+
20
+ export const WithErrorMessage = () => {
21
+ const [value, setValue] = useState<string | undefined>('This is some text');
22
+ return (
23
+ <Field label="Phone number" error="This is a required field">
24
+ <Input value={value} onChange={({ target }) => setValue(target.value)} />
25
+ </Field>
26
+ );
27
+ };
28
+
29
+ export const WithHelp = () => {
30
+ const [value, setValue] = useState<string | undefined>('This is some text');
31
+ return (
32
+ <Field label="Phone number" hint="This is a helpful message">
33
+ <Input value={value} onChange={({ target }) => setValue(target.value)} />
34
+ </Field>
35
+ );
36
+ };
37
+
38
+ export const WithHelpAndErrorOnBlur = () => {
39
+ const [value, setValue] = useState<string | undefined>('This is some text');
40
+ const [error, setError] = useState<string | undefined>(undefined);
41
+ return (
42
+ <Field label="Phone number" hint="Please include country code" error={error}>
43
+ <Input
44
+ value={value}
45
+ onChange={({ target }) => {
46
+ setValue(target.value);
47
+ setError(undefined);
48
+ }}
49
+ onBlur={() => {
50
+ if (!value) {
51
+ setError('This is a required field');
52
+ } else {
53
+ setError(undefined);
54
+ }
55
+ }}
56
+ />
57
+ </Field>
58
+ );
59
+ };