@transferwise/components 46.30.1 → 46.31.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 (58) hide show
  1. package/build/index.js +190 -57
  2. package/build/index.js.map +1 -1
  3. package/build/index.mjs +189 -58
  4. package/build/index.mjs.map +1 -1
  5. package/build/types/dateInput/DateInput.d.ts +5 -4
  6. package/build/types/dateInput/DateInput.d.ts.map +1 -1
  7. package/build/types/dateLookup/DateLookup.d.ts +11 -4
  8. package/build/types/dateLookup/DateLookup.d.ts.map +1 -1
  9. package/build/types/field/Field.d.ts +12 -0
  10. package/build/types/field/Field.d.ts.map +1 -0
  11. package/build/types/index.d.ts +4 -0
  12. package/build/types/index.d.ts.map +1 -1
  13. package/build/types/inputs/Input.d.ts.map +1 -1
  14. package/build/types/inputs/SelectInput.d.ts +1 -1
  15. package/build/types/inputs/SelectInput.d.ts.map +1 -1
  16. package/build/types/inputs/TextArea.d.ts.map +1 -1
  17. package/build/types/inputs/_common.d.ts +2 -2
  18. package/build/types/inputs/_common.d.ts.map +1 -1
  19. package/build/types/inputs/contexts.d.ts +24 -0
  20. package/build/types/inputs/contexts.d.ts.map +1 -0
  21. package/build/types/label/Label.d.ts +9 -0
  22. package/build/types/label/Label.d.ts.map +1 -0
  23. package/build/types/phoneNumberInput/PhoneNumberInput.d.ts +1 -1
  24. package/build/types/phoneNumberInput/PhoneNumberInput.d.ts.map +1 -1
  25. package/build/types/radioGroup/RadioGroup.d.ts.map +1 -1
  26. package/build/types/switch/Switch.d.ts +6 -3
  27. package/build/types/switch/Switch.d.ts.map +1 -1
  28. package/package.json +2 -2
  29. package/src/dateInput/DateInput.rtl.spec.tsx +17 -0
  30. package/src/dateInput/DateInput.tsx +28 -22
  31. package/src/dateLookup/DateLookup.keyboardEvents.spec.js +2 -2
  32. package/src/dateLookup/DateLookup.rtl.spec.tsx +21 -0
  33. package/src/dateLookup/DateLookup.state.spec.js +5 -5
  34. package/src/dateLookup/DateLookup.tests.story.tsx +4 -11
  35. package/src/dateLookup/DateLookup.tsx +24 -9
  36. package/src/dateLookup/DateLookup.view.spec.js +11 -11
  37. package/src/field/Field.spec.tsx +95 -0
  38. package/src/field/Field.story.tsx +59 -0
  39. package/src/field/Field.tsx +70 -0
  40. package/src/index.ts +4 -0
  41. package/src/inputs/Input.tsx +5 -3
  42. package/src/inputs/SelectInput.spec.tsx +10 -0
  43. package/src/inputs/SelectInput.tsx +9 -4
  44. package/src/inputs/TextArea.tsx +6 -3
  45. package/src/inputs/_ButtonInput.tsx +2 -2
  46. package/src/inputs/_common.ts +2 -2
  47. package/src/inputs/contexts.tsx +45 -0
  48. package/src/label/Label.spec.tsx +26 -0
  49. package/src/label/Label.story.tsx +37 -0
  50. package/src/label/Label.tsx +20 -0
  51. package/src/phoneNumberInput/PhoneNumberInput.story.tsx +16 -22
  52. package/src/phoneNumberInput/PhoneNumberInput.tsx +14 -2
  53. package/src/radioGroup/RadioGroup.rtl.spec.tsx +14 -0
  54. package/src/radioGroup/RadioGroup.story.tsx +26 -0
  55. package/src/radioGroup/RadioGroup.tsx +4 -1
  56. package/src/switch/Switch.spec.tsx +10 -0
  57. package/src/switch/Switch.tsx +22 -13
  58. package/src/utilities/logActionRequired.js +1 -1
@@ -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
+ };
@@ -0,0 +1,70 @@
1
+ import { useId } from '@radix-ui/react-id';
2
+ import classNames from 'classnames';
3
+
4
+ import { Sentiment } from '../common';
5
+ import InlineAlert from '../inlineAlert/InlineAlert';
6
+ import {
7
+ FieldLabelIdContextProvider,
8
+ InputDescribedByProvider,
9
+ InputIdContextProvider,
10
+ InputInvalidProvider,
11
+ } from '../inputs/contexts';
12
+ import { Label } from '../label/Label';
13
+
14
+ export type FieldProps = {
15
+ /** `null` disables auto-generating the `id` attribute, falling back to nesting-based label association over setting `htmlFor` explicitly. */
16
+ id?: string | null;
17
+ label: React.ReactNode;
18
+ hint?: React.ReactNode;
19
+ error?: React.ReactNode;
20
+ className?: string;
21
+ children?: React.ReactNode;
22
+ };
23
+
24
+ export const Field = ({ id, label, hint, error, className, children }: FieldProps) => {
25
+ const hasError = Boolean(error);
26
+ const hasHint = Boolean(hint) && !hasError;
27
+
28
+ const labelId = useId();
29
+
30
+ const fallbackInputId = useId(); // TODO: Use `React.useId()` when react>=18
31
+ const inputId = id !== null ? id ?? fallbackInputId : undefined;
32
+
33
+ const descriptionId = useId(); // TODO: Use `React.useId()` when react>=18
34
+
35
+ return (
36
+ <FieldLabelIdContextProvider value={labelId}>
37
+ <InputIdContextProvider value={inputId}>
38
+ <InputDescribedByProvider value={hasError || hasHint ? descriptionId : undefined}>
39
+ <InputInvalidProvider value={hasError}>
40
+ <div
41
+ className={classNames(
42
+ 'form-group d-block',
43
+ {
44
+ 'has-error': hasError,
45
+ 'has-info': hasHint,
46
+ },
47
+ className,
48
+ )}
49
+ >
50
+ <Label id={labelId} htmlFor={inputId}>
51
+ {label}
52
+ {children}
53
+ </Label>
54
+ {hasHint && (
55
+ <InlineAlert type={Sentiment.NEUTRAL} id={descriptionId}>
56
+ {hint}
57
+ </InlineAlert>
58
+ )}
59
+ {hasError && (
60
+ <InlineAlert type={Sentiment.NEGATIVE} id={descriptionId}>
61
+ {error}
62
+ </InlineAlert>
63
+ )}
64
+ </div>
65
+ </InputInvalidProvider>
66
+ </InputDescribedByProvider>
67
+ </InputIdContextProvider>
68
+ </FieldLabelIdContextProvider>
69
+ );
70
+ };
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ export type { DateLookupProps } from './dateLookup';
21
21
  export type { DecisionProps } from './decision/Decision';
22
22
  export type { DimmerProps } from './dimmer';
23
23
  export type { EmphasisProps } from './emphasis';
24
+ export type { FieldProps } from './field/Field';
24
25
  export type { InfoProps } from './info';
25
26
  export type { InputWithDisplayFormatProps } from './inputWithDisplayFormat';
26
27
  export type { InputProps } from './inputs/Input';
@@ -37,6 +38,7 @@ export type {
37
38
  } from './inputs/SelectInput';
38
39
  export type { TextAreaProps } from './inputs/TextArea';
39
40
  export type { InstructionsListProps } from './instructionsList';
41
+ export type { LabelProps } from './label/Label';
40
42
  export type { LoaderProps } from './loader';
41
43
  export type { MarkdownProps } from './markdown';
42
44
  export type { ModalProps } from './modal';
@@ -103,6 +105,7 @@ export { default as Drawer } from './drawer';
103
105
  export { default as DropFade } from './dropFade';
104
106
  export { default as Emphasis } from './emphasis';
105
107
  export { default as FlowNavigation } from './flowNavigation/FlowNavigation';
108
+ export { Field } from './field/Field';
106
109
  export { default as Header } from './header';
107
110
  export { default as Image } from './image';
108
111
  export { default as Info } from './info';
@@ -118,6 +121,7 @@ export {
118
121
  } from './inputs/SelectInput';
119
122
  export { TextArea } from './inputs/TextArea';
120
123
  export { default as InstructionsList } from './instructionsList';
124
+ export { Label } from './label/Label';
121
125
  export { default as Link } from './link';
122
126
  export { default as ListItem } from './listItem';
123
127
  export { default as Loader } from './loader';
@@ -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();
@@ -227,4 +228,13 @@ describe('SelectInput', () => {
227
228
  const trigger = screen.getAllByRole('button')[0];
228
229
  expect(trigger).toHaveAttribute('id', 'custom');
229
230
  });
231
+
232
+ it('supports `Field` for labeling', () => {
233
+ render(
234
+ <Field label="Currency">
235
+ <SelectInput items={[{ type: 'option', value: 'USD' }]} value="USD" />
236
+ </Field>,
237
+ );
238
+ expect(screen.getByLabelText('Currency')).toHaveTextContent('USD');
239
+ });
230
240
  });
@@ -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();
@@ -221,7 +222,7 @@ function SelectInputClearButton({ className, onClick }: SelectInputClearButtonPr
221
222
  const noop = () => {};
222
223
 
223
224
  export function SelectInput<T = string, M extends boolean = false>({
224
- id,
225
+ id: idProp,
225
226
  name,
226
227
  multiple,
227
228
  placeholder,
@@ -242,6 +243,9 @@ export function SelectInput<T = string, M extends boolean = false>({
242
243
  onClose,
243
244
  onClear,
244
245
  }: SelectInputProps<T, M>) {
246
+ const inputAttributes = useInputAttributes();
247
+ const id = idProp ?? inputAttributes.id;
248
+
245
249
  const [open, setOpen] = useState(false);
246
250
 
247
251
  const initialized = useRef(false);
@@ -308,6 +312,7 @@ export function SelectInput<T = string, M extends boolean = false>({
308
312
  ref(node);
309
313
  triggerRef.current = node;
310
314
  },
315
+ ...inputAttributes,
311
316
  id,
312
317
  ...mergeProps(
313
318
  {
@@ -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,45 @@
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 }: 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
+ }
29
+
30
+ export interface WithInputAttributesProps {
31
+ inputAttributes: ReturnType<typeof useInputAttributes>;
32
+ }
33
+
34
+ export function withInputAttributes<T extends Partial<WithInputAttributesProps>>(
35
+ Component: React.ComponentType<T>,
36
+ args?: UseInputAttributesArgs,
37
+ ) {
38
+ function ComponentWithInputAttributes(props: Omit<T, keyof WithInputAttributesProps>) {
39
+ return <Component inputAttributes={useInputAttributes(args)} {...(props as T)} />;
40
+ }
41
+
42
+ ComponentWithInputAttributes.displayName = `withInputAttributes(${Component.displayName || Component.name || 'Component'})`;
43
+
44
+ return ComponentWithInputAttributes;
45
+ }
@@ -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
+ };