@transferwise/components 0.0.0-experimental-946d3a7 → 0.0.0-experimental-bf4cb26

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 (45) hide show
  1. package/build/index.js +157 -57
  2. package/build/index.js.map +1 -1
  3. package/build/index.mjs +157 -59
  4. package/build/index.mjs.map +1 -1
  5. package/build/types/dateInput/DateInput.d.ts +3 -2
  6. package/build/types/dateInput/DateInput.d.ts.map +1 -1
  7. package/build/types/field/Field.d.ts +12 -0
  8. package/build/types/field/Field.d.ts.map +1 -0
  9. package/build/types/index.d.ts +4 -0
  10. package/build/types/index.d.ts.map +1 -1
  11. package/build/types/inputs/Input.d.ts.map +1 -1
  12. package/build/types/inputs/SelectInput.d.ts +1 -2
  13. package/build/types/inputs/SelectInput.d.ts.map +1 -1
  14. package/build/types/inputs/TextArea.d.ts.map +1 -1
  15. package/build/types/inputs/_common.d.ts +2 -2
  16. package/build/types/inputs/_common.d.ts.map +1 -1
  17. package/build/types/inputs/contexts.d.ts +17 -0
  18. package/build/types/inputs/contexts.d.ts.map +1 -0
  19. package/build/types/label/Label.d.ts +9 -0
  20. package/build/types/label/Label.d.ts.map +1 -0
  21. package/build/types/radioGroup/RadioGroup.d.ts.map +1 -1
  22. package/build/types/switch/Switch.d.ts +6 -3
  23. package/build/types/switch/Switch.d.ts.map +1 -1
  24. package/package.json +1 -1
  25. package/src/dateInput/DateInput.rtl.spec.tsx +17 -0
  26. package/src/dateInput/DateInput.tsx +24 -18
  27. package/src/field/Field.spec.tsx +95 -0
  28. package/src/field/Field.story.tsx +59 -0
  29. package/src/field/Field.tsx +70 -0
  30. package/src/index.ts +4 -0
  31. package/src/inputs/Input.tsx +5 -3
  32. package/src/inputs/SelectInput.spec.tsx +8 -9
  33. package/src/inputs/SelectInput.tsx +9 -9
  34. package/src/inputs/TextArea.tsx +6 -3
  35. package/src/inputs/_ButtonInput.tsx +2 -2
  36. package/src/inputs/_common.ts +2 -2
  37. package/src/inputs/contexts.ts +28 -0
  38. package/src/label/Label.spec.tsx +26 -0
  39. package/src/label/Label.story.tsx +37 -0
  40. package/src/label/Label.tsx +20 -0
  41. package/src/radioGroup/RadioGroup.rtl.spec.tsx +14 -0
  42. package/src/radioGroup/RadioGroup.story.tsx +26 -0
  43. package/src/radioGroup/RadioGroup.tsx +4 -1
  44. package/src/switch/Switch.spec.tsx +10 -0
  45. package/src/switch/Switch.tsx +22 -13
@@ -3,9 +3,9 @@ import { forwardRef } from 'react';
3
3
 
4
4
  import { SizeLarge, SizeMedium, SizeSmall } from '../common';
5
5
  import { Merge } from '../utils';
6
-
6
+ import { inputClassNameBase } from './_common';
7
+ import { useInputAttributes } from './contexts';
7
8
  import { useInputPaddings } from './InputGroup';
8
- import { formControlClassNameBase } from './_common';
9
9
 
10
10
  export interface InputProps
11
11
  extends Merge<
@@ -21,17 +21,19 @@ export const Input = forwardRef(function Input(
21
21
  { size = 'auto', shape = 'rectangle', className, ...restProps }: InputProps,
22
22
  reference: React.ForwardedRef<HTMLInputElement>,
23
23
  ) {
24
+ const inputAttributes = useInputAttributes();
24
25
  const inputPaddings = useInputPaddings();
25
26
 
26
27
  return (
27
28
  <input
28
29
  ref={reference}
29
- className={classNames(className, formControlClassNameBase({ size }), 'np-input', {
30
+ className={classNames(className, inputClassNameBase({ size }), 'np-input', {
30
31
  'np-input--shape-rectangle': shape === 'rectangle',
31
32
  'np-input--shape-pill': shape === 'pill',
32
33
  })}
33
34
  // eslint-disable-next-line react/forbid-dom-props
34
35
  style={inputPaddings}
36
+ {...inputAttributes}
35
37
  {...restProps}
36
38
  />
37
39
  );
@@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event';
4
4
  import { render, mockMatchMedia, mockResizeObserver } from '../test-utils';
5
5
 
6
6
  import { SelectInput } from './SelectInput';
7
+ import { Field } from '../field/Field';
7
8
 
8
9
  mockMatchMedia();
9
10
  mockResizeObserver();
@@ -216,14 +217,12 @@ describe('SelectInput', () => {
216
217
  expect(trigger).toHaveAttribute('id', 'custom');
217
218
  });
218
219
 
219
- it('supports `onBlur` handler', () => {
220
- const handleBlur = jest.fn();
221
-
222
- render(<SelectInput items={[]} onBlur={handleBlur} />);
223
-
224
- userEvent.tab();
225
- expect(handleBlur).not.toHaveBeenCalled();
226
- userEvent.tab();
227
- expect(handleBlur).toHaveBeenCalled();
220
+ it('supports `Field` for labeling', () => {
221
+ render(
222
+ <Field label="Currency">
223
+ <SelectInput items={[{ type: 'option', value: 'USD' }]} value="USD" />
224
+ </Field>,
225
+ );
226
+ expect(screen.getByLabelText('Currency')).toHaveTextContent('USD');
228
227
  });
229
228
  });
@@ -13,12 +13,13 @@ import { Breakpoint } from '../common/propsValues/breakpoint';
13
13
  import dateTriggerMessages from '../dateLookup/dateTrigger/DateTrigger.messages';
14
14
  import { Merge } from '../utils';
15
15
 
16
- import { InputGroup } from './InputGroup';
17
- import { SearchInput } from './SearchInput';
18
- import messages from './SelectInput.messages';
19
16
  import { BottomSheet } from './_BottomSheet';
20
17
  import { ButtonInput } from './_ButtonInput';
21
18
  import { Popover } from './_Popover';
19
+ import { useInputAttributes } from './contexts';
20
+ import { InputGroup } from './InputGroup';
21
+ import { SearchInput } from './SearchInput';
22
+ import messages from './SelectInput.messages';
22
23
 
23
24
  function searchableString(value: string) {
24
25
  return value.trim().replace(/\s+/gu, ' ').normalize('NFKC').toLowerCase();
@@ -156,7 +157,6 @@ export interface SelectInputProps<T = string, M extends boolean = false> {
156
157
  onFilterChange?: (args: { query: string; queryNormalized: string | null }) => void;
157
158
  onChange?: (value: M extends true ? T[] : T) => void;
158
159
  onClear?: () => void;
159
- onBlur?: () => void;
160
160
  }
161
161
 
162
162
  const defaultRenderTrigger = (({ content, placeholderShown, clear, disabled, size, className }) => (
@@ -221,7 +221,7 @@ function SelectInputClearButton({ className, onClick }: SelectInputClearButtonPr
221
221
  const noop = () => {};
222
222
 
223
223
  export function SelectInput<T = string, M extends boolean = false>({
224
- id,
224
+ id: idProp,
225
225
  name,
226
226
  multiple,
227
227
  placeholder,
@@ -240,8 +240,10 @@ export function SelectInput<T = string, M extends boolean = false>({
240
240
  onFilterChange = noop,
241
241
  onChange,
242
242
  onClear,
243
- onBlur,
244
243
  }: SelectInputProps<T, M>) {
244
+ const inputAttributes = useInputAttributes();
245
+ const id = idProp ?? inputAttributes.id;
246
+
245
247
  const [open, setOpen] = useState(false);
246
248
 
247
249
  const [filterQuery, _setFilterQuery] = useState('');
@@ -296,6 +298,7 @@ export function SelectInput<T = string, M extends boolean = false>({
296
298
  ref(node);
297
299
  triggerRef.current = node;
298
300
  },
301
+ ...inputAttributes,
299
302
  id,
300
303
  ...mergeProps(
301
304
  {
@@ -312,9 +315,6 @@ export function SelectInput<T = string, M extends boolean = false>({
312
315
  setOpen((prev) => !prev);
313
316
  }
314
317
  },
315
- onBlur: () => {
316
- onBlur?.();
317
- },
318
318
  },
319
319
  getInteractionProps(),
320
320
  ),
@@ -2,8 +2,8 @@ import classNames from 'classnames';
2
2
  import { forwardRef } from 'react';
3
3
 
4
4
  import { Merge } from '../utils';
5
-
6
- import { formControlClassNameBase } from './_common';
5
+ import { inputClassNameBase } from './_common';
6
+ import { useInputAttributes } from './contexts';
7
7
 
8
8
  export interface TextAreaProps
9
9
  extends Merge<
@@ -17,10 +17,13 @@ export const TextArea = forwardRef(function TextArea(
17
17
  { className, ...restProps }: TextAreaProps,
18
18
  reference: React.ForwardedRef<HTMLTextAreaElement>,
19
19
  ) {
20
+ const inputAttributes = useInputAttributes();
21
+
20
22
  return (
21
23
  <textarea
22
24
  ref={reference}
23
- className={classNames(className, formControlClassNameBase(), 'np-text-area')}
25
+ className={classNames(className, inputClassNameBase(), 'np-text-area')}
26
+ {...inputAttributes}
24
27
  {...restProps}
25
28
  />
26
29
  );
@@ -2,7 +2,7 @@ import classNames from 'classnames';
2
2
  import { forwardRef } from 'react';
3
3
 
4
4
  import { useInputPaddings } from './InputGroup';
5
- import { formControlClassNameBase } from './_common';
5
+ import { inputClassNameBase } from './_common';
6
6
 
7
7
  export interface ButtonInputProps extends React.ComponentPropsWithRef<'button'> {
8
8
  size?: 'sm' | 'md' | 'lg';
@@ -18,7 +18,7 @@ export const ButtonInput = forwardRef(function ButtonInput(
18
18
  <button
19
19
  ref={ref}
20
20
  type="button"
21
- className={classNames(className, formControlClassNameBase({ size }), 'np-button-input')}
21
+ className={classNames(className, inputClassNameBase({ size }), 'np-button-input')}
22
22
  // eslint-disable-next-line react/forbid-dom-props
23
23
  style={{ ...inputPaddings, ...style }}
24
24
  {...restProps}
@@ -2,11 +2,11 @@ import classNames from 'classnames';
2
2
 
3
3
  import { SizeLarge, SizeMedium, SizeSmall } from '../common';
4
4
 
5
- export type FormControlPropsBase = {
5
+ export type InputPropsBase = {
6
6
  size?: 'auto' | SizeSmall | SizeMedium | SizeLarge;
7
7
  };
8
8
 
9
- export function formControlClassNameBase({ size = 'auto' }: FormControlPropsBase = {}) {
9
+ export function inputClassNameBase({ size = 'auto' }: InputPropsBase = {}) {
10
10
  return classNames(
11
11
  'form-control', // TODO: Deprecate
12
12
  'np-form-control',
@@ -0,0 +1,28 @@
1
+ import { createContext, useContext } from 'react';
2
+
3
+ const FieldLabelIdContext = createContext<string | undefined>(undefined);
4
+ export const FieldLabelIdContextProvider = FieldLabelIdContext.Provider;
5
+
6
+ const InputIdContext = createContext<string | undefined>(undefined);
7
+ export const InputIdContextProvider = InputIdContext.Provider;
8
+
9
+ const InputDescribedByContext = createContext<string | undefined>(undefined);
10
+ export const InputDescribedByProvider = InputDescribedByContext.Provider;
11
+
12
+ const InputInvalidContext = createContext<boolean | undefined>(undefined);
13
+ export const InputInvalidProvider = InputInvalidContext.Provider;
14
+
15
+ interface UseInputAttributesArgs {
16
+ /** Set this to `true` if the underlying element is not directly [labelable as per the HTML specification](https://html.spec.whatwg.org/multipage/forms.html#category-label). */
17
+ nonLabelable?: boolean;
18
+ }
19
+
20
+ export function useInputAttributes({ nonLabelable = false }: UseInputAttributesArgs = {}) {
21
+ const labelId = useContext(FieldLabelIdContext);
22
+ return {
23
+ id: useContext(InputIdContext),
24
+ 'aria-labelledby': nonLabelable ? labelId : undefined,
25
+ 'aria-describedby': useContext(InputDescribedByContext),
26
+ 'aria-invalid': useContext(InputInvalidContext),
27
+ } satisfies React.HTMLAttributes<HTMLElement>;
28
+ }
@@ -0,0 +1,26 @@
1
+ import { Input } from '../inputs/Input';
2
+ import { render, screen } from '../test-utils';
3
+ import { Label } from './Label';
4
+
5
+ describe('Label', () => {
6
+ it('renders string labels', () => {
7
+ render(
8
+ <Label>
9
+ Phone number
10
+ <Input readOnly />
11
+ </Label>,
12
+ );
13
+
14
+ expect(screen.getByLabelText('Phone number')).toBeInTheDocument();
15
+ });
16
+ it('renders node type labels', () => {
17
+ render(
18
+ <Label>
19
+ <span>Phone number</span>
20
+ <Input readOnly />
21
+ </Label>,
22
+ );
23
+
24
+ expect(screen.getByLabelText('Phone number')).toBeInTheDocument();
25
+ });
26
+ });
@@ -0,0 +1,37 @@
1
+ import { useState } from 'react';
2
+
3
+ import Info from '../info/Info';
4
+ import { Input } from '../inputs/Input';
5
+ import { Label } from './Label';
6
+
7
+ export default {
8
+ component: Label,
9
+ title: 'Label',
10
+ };
11
+
12
+ export const Basic = () => {
13
+ const [value, setValue] = useState<string | undefined>('This is some text');
14
+ return (
15
+ <Label>
16
+ Phone number
17
+ <Input value={value} id="input" onChange={({ target }) => setValue(target.value)} />
18
+ </Label>
19
+ );
20
+ };
21
+
22
+ export const WithInfo = () => {
23
+ const [value, setValue] = useState<string | undefined>('This is some text');
24
+ return (
25
+ <Label>
26
+ <span className="d-flex">
27
+ Phone number{' '}
28
+ <Info
29
+ content="This is some help in popover"
30
+ aria-label="The aria label"
31
+ className="m-l-1"
32
+ />
33
+ </span>
34
+ <Input value={value} id="input" onChange={({ target }) => setValue(target.value)} />
35
+ </Label>
36
+ );
37
+ };
@@ -0,0 +1,20 @@
1
+ import classNames from 'classnames';
2
+
3
+ export type LabelProps = {
4
+ id?: string;
5
+ htmlFor?: string;
6
+ className?: string;
7
+ children?: React.ReactNode;
8
+ };
9
+
10
+ export const Label = ({ id, htmlFor, className, children }: LabelProps) => {
11
+ return (
12
+ <label
13
+ id={id}
14
+ htmlFor={htmlFor}
15
+ className={classNames('control-label d-flex flex-column gap-y-1 m-b-0', className)}
16
+ >
17
+ {children}
18
+ </label>
19
+ );
20
+ };
@@ -1,6 +1,7 @@
1
1
  import { render, screen } from '@testing-library/react';
2
2
 
3
3
  import RadioGroup from '.';
4
+ import { Field } from '../field/Field';
4
5
 
5
6
  describe('RadioGroup', () => {
6
7
  it('has accessible role', () => {
@@ -13,4 +14,17 @@ describe('RadioGroup', () => {
13
14
  );
14
15
  expect(screen.getByRole('radiogroup')).toBeInTheDocument();
15
16
  });
17
+
18
+ it('supports `Field` for labeling', () => {
19
+ render(
20
+ <Field label="Currency">
21
+ <RadioGroup
22
+ name="currency"
23
+ radios={[{ label: 'USD' }, { label: 'EUR' }]}
24
+ onChange={() => {}}
25
+ />
26
+ </Field>,
27
+ );
28
+ expect(screen.getByRole('radiogroup')).toHaveAccessibleName(/^Currency/);
29
+ });
16
30
  });
@@ -5,6 +5,7 @@ import { Flag } from '@wise/art';
5
5
  import Avatar, { AvatarType } from '../avatar';
6
6
 
7
7
  import RadioGroup from './RadioGroup';
8
+ import { Field } from '../field/Field';
8
9
 
9
10
  export default {
10
11
  component: RadioGroup,
@@ -53,3 +54,28 @@ export const Basic = () => {
53
54
  </div>
54
55
  );
55
56
  };
57
+
58
+ export const Labeled = () => {
59
+ return (
60
+ <Field label="Do you like our product?">
61
+ <RadioGroup
62
+ name="radio-group"
63
+ radios={[
64
+ {
65
+ value: 'yes',
66
+ label: 'Yes',
67
+ },
68
+ {
69
+ value: 'definitely',
70
+ label: 'Definitely',
71
+ },
72
+ {
73
+ value: 'absolutely',
74
+ label: 'Absolutely',
75
+ },
76
+ ]}
77
+ onChange={(v) => action(v)}
78
+ />
79
+ </Field>
80
+ );
81
+ };
@@ -2,6 +2,7 @@ import { useState } from 'react';
2
2
 
3
3
  import Radio from '../radio';
4
4
  import { RadioProps } from '../radio/Radio';
5
+ import { useInputAttributes } from '../inputs/contexts';
5
6
 
6
7
  export type RadioGroupRadio<T extends string | number = string> = Omit<
7
8
  RadioProps<T>,
@@ -21,10 +22,12 @@ export default function RadioGroup<T extends string | number = never>({
21
22
  selectedValue: controlledValue,
22
23
  onChange,
23
24
  }: RadioGroupProps<T>) {
25
+ const inputAttributes = useInputAttributes({ nonLabelable: true });
26
+
24
27
  const [uncontrolledValue, setUncontrolledValue] = useState(controlledValue);
25
28
 
26
29
  return radios.length > 0 ? (
27
- <div role="radiogroup">
30
+ <div role="radiogroup" {...inputAttributes}>
28
31
  {radios.map(({ value = '' as T, ...restProps }, index) => (
29
32
  <Radio
30
33
  // eslint-disable-next-line react/no-array-index-key
@@ -1,3 +1,4 @@
1
+ import { Field } from '../field/Field';
1
2
  import { render, fireEvent, screen } from '../test-utils';
2
3
 
3
4
  import Switch from './Switch';
@@ -81,4 +82,13 @@ describe('Switch', () => {
81
82
  fireEvent.click(input);
82
83
  expect(mockCallback).not.toHaveBeenCalled();
83
84
  });
85
+
86
+ it('supports `Field` for labeling', () => {
87
+ render(
88
+ <Field label="Dark mode">
89
+ <Switch checked onClick={props.onClick} />
90
+ </Field>,
91
+ );
92
+ expect(screen.getByLabelText('Dark mode')).toHaveAttribute('role', 'switch');
93
+ });
84
94
  });
@@ -1,13 +1,16 @@
1
1
  import { CheckCircleFill, CrossCircleFill } from '@transferwise/icons';
2
2
  import { useTheme } from '@wise/components-theming';
3
3
  import classnames from 'classnames';
4
- import { KeyboardEventHandler, MouseEvent } from 'react';
4
+ import type { KeyboardEventHandler, MouseEvent } from 'react';
5
5
 
6
- import { CommonProps } from '../common';
7
- import { logActionRequiredIf } from '../utilities';
6
+ import type { CommonProps } from '../common';
7
+ import { useInputAttributes } from '../inputs/contexts';
8
8
 
9
9
  export type SwitchProps = CommonProps & {
10
- /** Used to describe the purpose of the switch. To be used if there is no external label (i.e. aria-labelledby is null) */
10
+ /**
11
+ * Used to describe the purpose of the switch. To be used if there is no external label (i.e. aria-labelledby is null)
12
+ * @deprecated Use `Field` wrapper or the `aria-labelledby` attribute instead.
13
+ */
11
14
  'aria-label'?: string;
12
15
  /** A reference to a label that describes the purpose of the switch. Ignored if aria-label is provided */
13
16
  'aria-labelledby'?: string;
@@ -21,8 +24,18 @@ export type SwitchProps = CommonProps & {
21
24
  };
22
25
 
23
26
  const Switch = (props: SwitchProps) => {
27
+ const inputAttributes = useInputAttributes({ nonLabelable: true });
28
+
24
29
  const { isModern } = useTheme();
25
- const { checked, className, id, onClick, disabled } = props;
30
+ const {
31
+ checked,
32
+ className,
33
+ id = inputAttributes.id,
34
+ 'aria-label': ariaLabel,
35
+ 'aria-labelledby': ariaLabelledbyProp,
36
+ onClick,
37
+ disabled,
38
+ } = props;
26
39
 
27
40
  const handleKeyDown: KeyboardEventHandler = (event) => {
28
41
  if (event.key === ' ') {
@@ -50,13 +63,8 @@ const Switch = (props: SwitchProps) => {
50
63
  );
51
64
  };
52
65
 
53
- const ariaLabel = props['aria-label'];
54
- const ariaLabelledby = ariaLabel ? undefined : props['aria-labelledby'];
55
-
56
- logActionRequiredIf(
57
- 'Switch now expects either `aria-label` or `aria-labelledby`, and will soon make these props required. Please update your usage to provide one or the other.',
58
- !ariaLabel && !ariaLabelledby,
59
- );
66
+ const ariaLabelledby =
67
+ (ariaLabel ? undefined : ariaLabelledbyProp) ?? inputAttributes['aria-labelledby'];
60
68
 
61
69
  return (
62
70
  <span
@@ -66,7 +74,7 @@ const Switch = (props: SwitchProps) => {
66
74
  {
67
75
  'np-switch--unchecked': !checked,
68
76
  'np-switch--checked': checked,
69
- disabled: disabled,
77
+ disabled,
70
78
  },
71
79
  className,
72
80
  )}
@@ -74,6 +82,7 @@ const Switch = (props: SwitchProps) => {
74
82
  role="switch"
75
83
  aria-checked={checked}
76
84
  aria-label={ariaLabel}
85
+ {...inputAttributes}
77
86
  aria-labelledby={ariaLabelledby}
78
87
  id={id}
79
88
  aria-disabled={disabled}