@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.
- package/build/index.js +190 -57
- package/build/index.js.map +1 -1
- package/build/index.mjs +189 -58
- package/build/index.mjs.map +1 -1
- package/build/types/dateInput/DateInput.d.ts +5 -4
- package/build/types/dateInput/DateInput.d.ts.map +1 -1
- package/build/types/dateLookup/DateLookup.d.ts +11 -4
- package/build/types/dateLookup/DateLookup.d.ts.map +1 -1
- package/build/types/field/Field.d.ts +12 -0
- package/build/types/field/Field.d.ts.map +1 -0
- package/build/types/index.d.ts +4 -0
- package/build/types/index.d.ts.map +1 -1
- package/build/types/inputs/Input.d.ts.map +1 -1
- package/build/types/inputs/SelectInput.d.ts +1 -1
- package/build/types/inputs/SelectInput.d.ts.map +1 -1
- package/build/types/inputs/TextArea.d.ts.map +1 -1
- package/build/types/inputs/_common.d.ts +2 -2
- package/build/types/inputs/_common.d.ts.map +1 -1
- package/build/types/inputs/contexts.d.ts +24 -0
- package/build/types/inputs/contexts.d.ts.map +1 -0
- package/build/types/label/Label.d.ts +9 -0
- package/build/types/label/Label.d.ts.map +1 -0
- package/build/types/phoneNumberInput/PhoneNumberInput.d.ts +1 -1
- package/build/types/phoneNumberInput/PhoneNumberInput.d.ts.map +1 -1
- package/build/types/radioGroup/RadioGroup.d.ts.map +1 -1
- package/build/types/switch/Switch.d.ts +6 -3
- package/build/types/switch/Switch.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/dateInput/DateInput.rtl.spec.tsx +17 -0
- package/src/dateInput/DateInput.tsx +28 -22
- package/src/dateLookup/DateLookup.keyboardEvents.spec.js +2 -2
- package/src/dateLookup/DateLookup.rtl.spec.tsx +21 -0
- package/src/dateLookup/DateLookup.state.spec.js +5 -5
- package/src/dateLookup/DateLookup.tests.story.tsx +4 -11
- package/src/dateLookup/DateLookup.tsx +24 -9
- package/src/dateLookup/DateLookup.view.spec.js +11 -11
- package/src/field/Field.spec.tsx +95 -0
- package/src/field/Field.story.tsx +59 -0
- package/src/field/Field.tsx +70 -0
- package/src/index.ts +4 -0
- package/src/inputs/Input.tsx +5 -3
- package/src/inputs/SelectInput.spec.tsx +10 -0
- package/src/inputs/SelectInput.tsx +9 -4
- package/src/inputs/TextArea.tsx +6 -3
- package/src/inputs/_ButtonInput.tsx +2 -2
- package/src/inputs/_common.ts +2 -2
- package/src/inputs/contexts.tsx +45 -0
- package/src/label/Label.spec.tsx +26 -0
- package/src/label/Label.story.tsx +37 -0
- package/src/label/Label.tsx +20 -0
- package/src/phoneNumberInput/PhoneNumberInput.story.tsx +16 -22
- package/src/phoneNumberInput/PhoneNumberInput.tsx +14 -2
- package/src/radioGroup/RadioGroup.rtl.spec.tsx +14 -0
- package/src/radioGroup/RadioGroup.story.tsx +26 -0
- package/src/radioGroup/RadioGroup.tsx +4 -1
- package/src/switch/Switch.spec.tsx +10 -0
- package/src/switch/Switch.tsx +22 -13
- 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<
|
|
52
|
-
declare props:
|
|
53
|
-
Required<Pick<
|
|
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<
|
|
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:
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
80
|
-
expect(
|
|
81
|
-
expect(
|
|
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(
|
|
107
|
-
expect(
|
|
108
|
-
expect(
|
|
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(
|
|
133
|
-
expect(
|
|
134
|
-
expect(
|
|
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';
|
package/src/inputs/Input.tsx
CHANGED
|
@@ -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,
|
|
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
|
{
|
package/src/inputs/TextArea.tsx
CHANGED
|
@@ -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 {
|
|
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,
|
|
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 {
|
|
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,
|
|
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}
|
package/src/inputs/_common.ts
CHANGED
|
@@ -2,11 +2,11 @@ import classNames from 'classnames';
|
|
|
2
2
|
|
|
3
3
|
import { SizeLarge, SizeMedium, SizeSmall } from '../common';
|
|
4
4
|
|
|
5
|
-
export type
|
|
5
|
+
export type InputPropsBase = {
|
|
6
6
|
size?: 'auto' | SizeSmall | SizeMedium | SizeLarge;
|
|
7
7
|
};
|
|
8
8
|
|
|
9
|
-
export function
|
|
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
|
+
};
|