uikit-react-public 0.48.1 → 0.49.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/ConfidentialDisplayField/ConfidentialDisplayField.d.ts +10 -0
- package/dist/components/ConfidentialDisplayField/ConfidentialDisplayField.stories.d.ts +19 -0
- package/dist/components/ConfidentialDisplayField/__tests__/ConfidentialDisplayField.test.d.ts +1 -0
- package/dist/components/ConfidentialDisplayField/index.d.ts +2 -0
- package/dist/components/ConfidentialInput/ConfidentialInput.d.ts +2 -1
- package/dist/components/DisplayField/DisplayField.d.ts +14 -0
- package/dist/components/DisplayField/DisplayField.stories.d.ts +19 -0
- package/dist/components/DisplayField/__tests__/DisplayField.test.d.ts +1 -0
- package/dist/components/DisplayField/index.d.ts +2 -0
- package/dist/components/Input/Input.d.ts +2 -0
- package/dist/components/Input/Input.stories.d.ts +1 -0
- package/dist/components/common/formatConfidentialValue.d.ts +3 -0
- package/dist/components/common/formatDisplayValue.d.ts +3 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/index.js +5941 -5764
- package/lib/components/ConfidentialDisplayField/ConfidentialDisplayField.stories.tsx +43 -0
- package/lib/components/ConfidentialDisplayField/ConfidentialDisplayField.tsx +105 -0
- package/lib/components/ConfidentialDisplayField/Documentation.mdx +46 -0
- package/lib/components/ConfidentialDisplayField/__tests__/ConfidentialDisplayField.test.tsx +144 -0
- package/lib/components/ConfidentialDisplayField/index.ts +2 -0
- package/lib/components/ConfidentialInput/ConfidentialInput.stories.tsx +2 -2
- package/lib/components/ConfidentialInput/ConfidentialInput.tsx +21 -35
- package/lib/components/ConfidentialInput/Documentation.mdx +4 -2
- package/lib/components/ConfidentialInput/__tests__/ConfidentialInput.test.tsx +33 -7
- package/lib/components/DisplayField/DisplayField.stories.tsx +48 -0
- package/lib/components/DisplayField/DisplayField.tsx +68 -0
- package/lib/components/DisplayField/Documentation.mdx +44 -0
- package/lib/components/DisplayField/__tests__/DisplayField.test.tsx +96 -0
- package/lib/components/DisplayField/index.ts +2 -0
- package/lib/components/Input/Documentation.mdx +11 -0
- package/lib/components/Input/Input.stories.tsx +7 -0
- package/lib/components/Input/Input.tsx +134 -2
- package/lib/components/Input/__tests__/Input.test.tsx +96 -1
- package/lib/components/common/formatConfidentialValue.ts +36 -0
- package/lib/components/common/formatDisplayValue.ts +29 -0
- package/lib/components/index.ts +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { forwardRef, memo, OutputHTMLAttributes, use } from 'react';
|
|
2
|
+
import { css, cx } from '@emotion/css';
|
|
3
|
+
import useTheme from '../../theme/useTheme';
|
|
4
|
+
import { FieldContext } from '../Field';
|
|
5
|
+
import marginsStyle, { MarginProps } from '../common/marginsStyle';
|
|
6
|
+
import formatDisplayValue, {
|
|
7
|
+
DisplayValueFormat,
|
|
8
|
+
} from '../common/formatDisplayValue';
|
|
9
|
+
|
|
10
|
+
export const NAME = 'ucl-uikit-display-field';
|
|
11
|
+
|
|
12
|
+
export interface DisplayFieldBaseProps extends Omit<
|
|
13
|
+
OutputHTMLAttributes<HTMLOutputElement>,
|
|
14
|
+
'children' | 'value'
|
|
15
|
+
> {
|
|
16
|
+
value: string | number;
|
|
17
|
+
numeric?: boolean;
|
|
18
|
+
format?: DisplayValueFormat;
|
|
19
|
+
testId?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type DisplayFieldProps = DisplayFieldBaseProps & MarginProps;
|
|
23
|
+
|
|
24
|
+
export type Ref = HTMLOutputElement;
|
|
25
|
+
|
|
26
|
+
const DisplayField = forwardRef<Ref, DisplayFieldProps>(
|
|
27
|
+
(
|
|
28
|
+
{ value, numeric = false, format, testId = NAME, className, ...props },
|
|
29
|
+
ref
|
|
30
|
+
) => {
|
|
31
|
+
const [theme] = useTheme();
|
|
32
|
+
const { id: contextId } = use(FieldContext);
|
|
33
|
+
const id = props.id ?? contextId;
|
|
34
|
+
const useNumericTypography = typeof value === 'number' || numeric;
|
|
35
|
+
const typography = useNumericTypography
|
|
36
|
+
? theme.typography.body.mdNumeric
|
|
37
|
+
: theme.typography.body.md;
|
|
38
|
+
const displayedValue = formatDisplayValue(String(value), format);
|
|
39
|
+
|
|
40
|
+
const baseStyle = css`
|
|
41
|
+
display: block;
|
|
42
|
+
min-height: 24px;
|
|
43
|
+
color: ${theme.colour.text.default};
|
|
44
|
+
font-family: ${typography.fontFamily};
|
|
45
|
+
font-feature-settings: ${typography.fontSettings};
|
|
46
|
+
font-size: ${typography.fontSize}px;
|
|
47
|
+
font-weight: ${typography.fontWeight};
|
|
48
|
+
line-height: ${typography.lineHeight}%;
|
|
49
|
+
overflow-wrap: anywhere;
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
const style = cx(NAME, baseStyle, marginsStyle(props, theme), className);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<output
|
|
56
|
+
{...props}
|
|
57
|
+
ref={ref}
|
|
58
|
+
id={id}
|
|
59
|
+
className={style}
|
|
60
|
+
data-testid={testId}
|
|
61
|
+
>
|
|
62
|
+
{displayedValue}
|
|
63
|
+
</output>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
export default memo(DisplayField);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as DisplayFieldStories from './DisplayField.stories';
|
|
2
|
+
import {
|
|
3
|
+
Meta,
|
|
4
|
+
Title,
|
|
5
|
+
Subtitle,
|
|
6
|
+
Canvas,
|
|
7
|
+
Source,
|
|
8
|
+
ArgTypes,
|
|
9
|
+
} from '@storybook/addon-docs/blocks';
|
|
10
|
+
|
|
11
|
+
<Meta of={DisplayFieldStories} />
|
|
12
|
+
<Title />
|
|
13
|
+
<Subtitle>A read-only field for displaying a single value</Subtitle>
|
|
14
|
+
|
|
15
|
+
`DisplayField` renders a semantic HTML `<output>` element. Use it with `Label`
|
|
16
|
+
to display a record in the same field structure used by editable forms.
|
|
17
|
+
|
|
18
|
+
<Source
|
|
19
|
+
code={`<Field>
|
|
20
|
+
<Label>Account name</Label>
|
|
21
|
+
<DisplayField value='Personal savings' />
|
|
22
|
+
</Field>`}
|
|
23
|
+
/>
|
|
24
|
+
|
|
25
|
+
## Props
|
|
26
|
+
|
|
27
|
+
- `value`: **string | number** - The value to display.
|
|
28
|
+
- `numeric`: **boolean** - Applies numeric typography to string values. Number
|
|
29
|
+
values use numeric typography automatically. Defaults to `false`.
|
|
30
|
+
- `format`: **string | (value: string) => string** - Formats the displayed
|
|
31
|
+
value. In a string template, each `#` consumes one value character and other
|
|
32
|
+
characters are displayed literally. For example, `##-##-##` formats `112233`
|
|
33
|
+
as `11-22-33`.
|
|
34
|
+
- All standard HTML `<output>` props are supported.
|
|
35
|
+
|
|
36
|
+
<ArgTypes />
|
|
37
|
+
|
|
38
|
+
## Examples
|
|
39
|
+
|
|
40
|
+
<Canvas of={DisplayFieldStories.Text} />
|
|
41
|
+
<Canvas of={DisplayFieldStories.Number} />
|
|
42
|
+
<Canvas of={DisplayFieldStories.NumericString} />
|
|
43
|
+
<Canvas of={DisplayFieldStories.StringFormat} />
|
|
44
|
+
<Canvas of={DisplayFieldStories.FunctionFormat} />
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createRef } from 'react';
|
|
2
|
+
import { describe, expect, test } from 'vitest';
|
|
3
|
+
import { render, screen } from '@testing-library/react';
|
|
4
|
+
import { ThemeContextProvider } from '../../../theme/useTheme';
|
|
5
|
+
import DisplayField from '../DisplayField';
|
|
6
|
+
import Field from '../../Field';
|
|
7
|
+
import Label from '../../Label';
|
|
8
|
+
|
|
9
|
+
const renderDisplayField = (props: React.ComponentProps<typeof DisplayField>) =>
|
|
10
|
+
render(
|
|
11
|
+
<ThemeContextProvider>
|
|
12
|
+
<DisplayField {...props} />
|
|
13
|
+
</ThemeContextProvider>
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
describe('DisplayField', () => {
|
|
17
|
+
test('displays a text value', () => {
|
|
18
|
+
renderDisplayField({ value: 'Personal savings' });
|
|
19
|
+
|
|
20
|
+
expect(screen.getByTestId('ucl-uikit-display-field')).toHaveTextContent(
|
|
21
|
+
'Personal savings'
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('displays a numeric value', () => {
|
|
26
|
+
renderDisplayField({ value: 12345678 });
|
|
27
|
+
|
|
28
|
+
const output = screen.getByTestId('ucl-uikit-display-field');
|
|
29
|
+
expect(output).toHaveTextContent('12345678');
|
|
30
|
+
expect(getComputedStyle(output).fontFamily).toContain('DM Mono');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('uses numeric typography for a string when numeric is true', () => {
|
|
34
|
+
renderDisplayField({ value: '00123456', numeric: true });
|
|
35
|
+
|
|
36
|
+
expect(
|
|
37
|
+
getComputedStyle(screen.getByTestId('ucl-uikit-display-field')).fontFamily
|
|
38
|
+
).toContain('DM Mono');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('formats a value with a string template', () => {
|
|
42
|
+
renderDisplayField({ value: '112233', format: '##-##-##' });
|
|
43
|
+
|
|
44
|
+
expect(screen.getByTestId('ucl-uikit-display-field')).toHaveTextContent(
|
|
45
|
+
'11-22-33'
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('appends value characters beyond a string template', () => {
|
|
50
|
+
renderDisplayField({ value: '11223344', format: '##-##-##' });
|
|
51
|
+
|
|
52
|
+
expect(screen.getByTestId('ucl-uikit-display-field')).toHaveTextContent(
|
|
53
|
+
'11-22-3344'
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('formats a value with a function', () => {
|
|
58
|
+
renderDisplayField({
|
|
59
|
+
value: 1234.5,
|
|
60
|
+
format: (value) => `£${value}`,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(screen.getByTestId('ucl-uikit-display-field')).toHaveTextContent(
|
|
64
|
+
'£1234.5'
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('shares the Field id with its Label', () => {
|
|
69
|
+
render(
|
|
70
|
+
<ThemeContextProvider>
|
|
71
|
+
<Field>
|
|
72
|
+
<Label>Account name</Label>
|
|
73
|
+
<DisplayField value='Personal savings' />
|
|
74
|
+
</Field>
|
|
75
|
+
</ThemeContextProvider>
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const label = screen.getByText('Account name');
|
|
79
|
+
const output = screen.getByTestId('ucl-uikit-display-field');
|
|
80
|
+
expect(label).toHaveAttribute('for', output.id);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('forwards its ref to the output', () => {
|
|
84
|
+
const ref = createRef<HTMLOutputElement>();
|
|
85
|
+
render(
|
|
86
|
+
<ThemeContextProvider>
|
|
87
|
+
<DisplayField
|
|
88
|
+
ref={ref}
|
|
89
|
+
value='Personal savings'
|
|
90
|
+
/>
|
|
91
|
+
</ThemeContextProvider>
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
expect(ref.current).toBe(screen.getByTestId('ucl-uikit-display-field'));
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -17,6 +17,7 @@ export const usage = {
|
|
|
17
17
|
rightIcon: `<Input icon={<Icon.Settings />} iconPosition='right' type='password' />`,
|
|
18
18
|
password: `<Input type='password' />`,
|
|
19
19
|
email: `<Input type='email' icon={<Icon.Facebook />} iconPosition='left' />`,
|
|
20
|
+
formatted: `<Input defaultValue='112233' format='##-##-##' />`,
|
|
20
21
|
};
|
|
21
22
|
|
|
22
23
|
<Meta of={InputStories} />
|
|
@@ -47,6 +48,10 @@ Use the Input component wherever standard inputs are required with optional enha
|
|
|
47
48
|
- `type`: **string** – Input type (e.g. `text`, `password`, `email`, etc.)
|
|
48
49
|
- `disabled`: **boolean** – Disables the input field
|
|
49
50
|
- `placeholder`: **string** – Placeholder text
|
|
51
|
+
- `format`: **string | (value: string) => string** – Formats the displayed
|
|
52
|
+
value while the input is blurred. `#` consumes one raw value character;
|
|
53
|
+
other template characters are displayed literally. The raw value is shown
|
|
54
|
+
while focused and remains unchanged for events and form submission.
|
|
50
55
|
- All standard HTML `<input>` props are supported
|
|
51
56
|
|
|
52
57
|
# Variants
|
|
@@ -92,3 +97,9 @@ Use the Input component wherever standard inputs are required with optional enha
|
|
|
92
97
|
sourceState='shown'
|
|
93
98
|
source={{ code: usage.email }}
|
|
94
99
|
/>
|
|
100
|
+
|
|
101
|
+
<Canvas
|
|
102
|
+
of={InputStories.Formatted}
|
|
103
|
+
sourceState='shown'
|
|
104
|
+
source={{ code: usage.formatted }}
|
|
105
|
+
/>
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
|
+
ChangeEvent,
|
|
3
|
+
FocusEvent,
|
|
2
4
|
memo,
|
|
3
5
|
InputHTMLAttributes,
|
|
4
6
|
forwardRef,
|
|
5
7
|
use,
|
|
8
|
+
useCallback,
|
|
9
|
+
useEffect,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
6
12
|
ReactElement,
|
|
7
13
|
} from 'react';
|
|
8
14
|
import useTheme from '../../theme/useTheme';
|
|
@@ -11,6 +17,9 @@ import marginsStyle, { MarginProps } from '../common/marginsStyle';
|
|
|
11
17
|
import { FieldContext } from '../Field';
|
|
12
18
|
import { IconProps } from '../Icon';
|
|
13
19
|
import { IconButtonProps } from '../IconButton/IconButton';
|
|
20
|
+
import formatDisplayValue, {
|
|
21
|
+
DisplayValueFormat,
|
|
22
|
+
} from '../common/formatDisplayValue';
|
|
14
23
|
|
|
15
24
|
export const NAME = 'ucl-uikit-input';
|
|
16
25
|
export const INPUT_NAME = 'ucl-uikit-input__input';
|
|
@@ -20,6 +29,7 @@ export interface InputBaseProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
|
20
29
|
iconPosition?: 'left' | 'right';
|
|
21
30
|
iconButton?: ReactElement<IconButtonProps>;
|
|
22
31
|
inputClassName?: string;
|
|
32
|
+
format?: DisplayValueFormat;
|
|
23
33
|
testId?: string;
|
|
24
34
|
}
|
|
25
35
|
|
|
@@ -35,17 +45,73 @@ const Input = forwardRef<Ref, InputProps>(
|
|
|
35
45
|
iconPosition = 'left',
|
|
36
46
|
iconButton,
|
|
37
47
|
inputClassName,
|
|
48
|
+
format,
|
|
38
49
|
className,
|
|
50
|
+
value,
|
|
51
|
+
defaultValue,
|
|
52
|
+
onChange,
|
|
53
|
+
onFocus,
|
|
54
|
+
onBlur,
|
|
39
55
|
...props
|
|
40
56
|
},
|
|
41
|
-
|
|
57
|
+
forwardedRef
|
|
42
58
|
) => {
|
|
43
59
|
const [theme] = useTheme();
|
|
44
60
|
const { md } = theme.typography.body;
|
|
61
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
62
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
63
|
+
const [uncontrolledValue, setUncontrolledValue] = useState(() => {
|
|
64
|
+
if (defaultValue === undefined || defaultValue === null) return '';
|
|
65
|
+
return Array.isArray(defaultValue)
|
|
66
|
+
? defaultValue.join(',')
|
|
67
|
+
: String(defaultValue);
|
|
68
|
+
});
|
|
45
69
|
|
|
46
70
|
const { disabled: contextDisabled, id: contextId } = use(FieldContext);
|
|
47
71
|
const disabled = props.disabled ?? contextDisabled;
|
|
48
72
|
const id = props.id ?? contextId;
|
|
73
|
+
const stringValue =
|
|
74
|
+
value === undefined
|
|
75
|
+
? uncontrolledValue
|
|
76
|
+
: Array.isArray(value)
|
|
77
|
+
? value.join(',')
|
|
78
|
+
: String(value);
|
|
79
|
+
const formattedValue = formatDisplayValue(stringValue, format);
|
|
80
|
+
const formattedValueIsVisible =
|
|
81
|
+
Boolean(format) && !isFocused && stringValue.length > 0;
|
|
82
|
+
|
|
83
|
+
const setInputRef = useCallback(
|
|
84
|
+
(element: HTMLInputElement | null) => {
|
|
85
|
+
inputRef.current = element;
|
|
86
|
+
|
|
87
|
+
if (typeof forwardedRef === 'function') {
|
|
88
|
+
forwardedRef(element);
|
|
89
|
+
} else if (forwardedRef) {
|
|
90
|
+
forwardedRef.current = element;
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
[forwardedRef]
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
const input = inputRef.current;
|
|
98
|
+
const form = input?.form;
|
|
99
|
+
if (!input || !form || value !== undefined || !format) return;
|
|
100
|
+
|
|
101
|
+
let resetTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
102
|
+
const handleReset = () => {
|
|
103
|
+
if (resetTimeout) clearTimeout(resetTimeout);
|
|
104
|
+
resetTimeout = setTimeout(() => {
|
|
105
|
+
setUncontrolledValue(input.value);
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
form.addEventListener('reset', handleReset);
|
|
110
|
+
return () => {
|
|
111
|
+
form.removeEventListener('reset', handleReset);
|
|
112
|
+
if (resetTimeout) clearTimeout(resetTimeout);
|
|
113
|
+
};
|
|
114
|
+
}, [format, value]);
|
|
49
115
|
|
|
50
116
|
const inputBaseStyle = css`
|
|
51
117
|
height: 48px;
|
|
@@ -92,6 +158,11 @@ const Input = forwardRef<Ref, InputProps>(
|
|
|
92
158
|
padding-right: ${theme.padding.p80};
|
|
93
159
|
`;
|
|
94
160
|
|
|
161
|
+
const formattedInputStyle = css`
|
|
162
|
+
color: transparent;
|
|
163
|
+
-webkit-text-fill-color: transparent;
|
|
164
|
+
`;
|
|
165
|
+
|
|
95
166
|
const hasRightIconAndButton = Boolean(
|
|
96
167
|
icon && iconPosition === 'right' && iconButton
|
|
97
168
|
);
|
|
@@ -106,6 +177,7 @@ const Input = forwardRef<Ref, InputProps>(
|
|
|
106
177
|
!hasRightIconAndButton &&
|
|
107
178
|
padRightStyle,
|
|
108
179
|
hasRightIconAndButton && padRightForTwoControlsStyle,
|
|
180
|
+
formattedValueIsVisible && formattedInputStyle,
|
|
109
181
|
inputClassName
|
|
110
182
|
);
|
|
111
183
|
|
|
@@ -150,16 +222,76 @@ const Input = forwardRef<Ref, InputProps>(
|
|
|
150
222
|
|
|
151
223
|
const iconButtonStyle = cx(iconWrapperBaseStyle, rightPositionStyle);
|
|
152
224
|
|
|
225
|
+
const formattedValueStyle = css`
|
|
226
|
+
position: absolute;
|
|
227
|
+
top: 0;
|
|
228
|
+
bottom: 0;
|
|
229
|
+
left: ${
|
|
230
|
+
icon && iconPosition === 'left' ? theme.padding.p48 : theme.padding.p16
|
|
231
|
+
};
|
|
232
|
+
right: ${
|
|
233
|
+
hasRightIconAndButton
|
|
234
|
+
? theme.padding.p80
|
|
235
|
+
: (icon && iconPosition === 'right') || iconButton
|
|
236
|
+
? theme.padding.p48
|
|
237
|
+
: theme.padding.p16
|
|
238
|
+
};
|
|
239
|
+
display: flex;
|
|
240
|
+
align-items: center;
|
|
241
|
+
overflow: hidden;
|
|
242
|
+
color: ${
|
|
243
|
+
disabled ? theme.colour.text.disabled : theme.colour.text.default
|
|
244
|
+
};
|
|
245
|
+
font-family: ${md.fontFamily};
|
|
246
|
+
font-feature-settings: ${md.fontSettings};
|
|
247
|
+
font-size: ${md.fontSize}px;
|
|
248
|
+
font-weight: ${md.fontWeight};
|
|
249
|
+
line-height: ${md.lineHeight}%;
|
|
250
|
+
pointer-events: none;
|
|
251
|
+
white-space: nowrap;
|
|
252
|
+
`;
|
|
253
|
+
|
|
254
|
+
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
255
|
+
if (value === undefined) {
|
|
256
|
+
setUncontrolledValue(event.target.value);
|
|
257
|
+
}
|
|
258
|
+
onChange?.(event);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const handleFocus = (event: FocusEvent<HTMLInputElement>) => {
|
|
262
|
+
setIsFocused(true);
|
|
263
|
+
onFocus?.(event);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
|
|
267
|
+
setIsFocused(false);
|
|
268
|
+
onBlur?.(event);
|
|
269
|
+
};
|
|
270
|
+
|
|
153
271
|
return (
|
|
154
272
|
<span className={wrapperClass}>
|
|
155
273
|
<input
|
|
156
|
-
ref={
|
|
274
|
+
ref={setInputRef}
|
|
157
275
|
id={id}
|
|
158
276
|
className={inputStyle}
|
|
159
277
|
data-testid={testId}
|
|
160
278
|
disabled={disabled}
|
|
279
|
+
value={value}
|
|
280
|
+
defaultValue={defaultValue}
|
|
281
|
+
onChange={handleChange}
|
|
282
|
+
onFocus={handleFocus}
|
|
283
|
+
onBlur={handleBlur}
|
|
161
284
|
{...props}
|
|
162
285
|
/>
|
|
286
|
+
{formattedValueIsVisible && (
|
|
287
|
+
<span
|
|
288
|
+
className={formattedValueStyle}
|
|
289
|
+
data-testid={`${testId}-formatted-value`}
|
|
290
|
+
aria-hidden='true'
|
|
291
|
+
>
|
|
292
|
+
{formattedValue}
|
|
293
|
+
</span>
|
|
294
|
+
)}
|
|
163
295
|
{icon && <span className={iconWrapperStyle}>{icon}</span>}
|
|
164
296
|
{iconButton && <span className={iconButtonStyle}>{iconButton}</span>}
|
|
165
297
|
</span>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'vitest';
|
|
2
|
-
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
3
3
|
import userEvent from '@testing-library/user-event';
|
|
4
4
|
import Input from '../Input';
|
|
5
5
|
import { ThemeContextProvider } from '../../../theme/useTheme';
|
|
@@ -139,4 +139,99 @@ describe('Input', () => {
|
|
|
139
139
|
expect(iconWrapper).toHaveStyle({ right: '48px' });
|
|
140
140
|
expect(buttonWrapper).toHaveStyle({ right: '16px' });
|
|
141
141
|
});
|
|
142
|
+
|
|
143
|
+
test('formats an uncontrolled value while blurred', async () => {
|
|
144
|
+
const user = userEvent.setup();
|
|
145
|
+
render(
|
|
146
|
+
<ThemeContextProvider>
|
|
147
|
+
<Input
|
|
148
|
+
defaultValue='112233'
|
|
149
|
+
format='##-##-##'
|
|
150
|
+
/>
|
|
151
|
+
</ThemeContextProvider>
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const input = screen.getByTestId('ucl-uikit-input');
|
|
155
|
+
expect(input).toHaveValue('112233');
|
|
156
|
+
expect(
|
|
157
|
+
screen.getByTestId('ucl-uikit-input-formatted-value')
|
|
158
|
+
).toHaveTextContent('11-22-33');
|
|
159
|
+
|
|
160
|
+
await user.click(input);
|
|
161
|
+
expect(
|
|
162
|
+
screen.queryByTestId('ucl-uikit-input-formatted-value')
|
|
163
|
+
).not.toBeInTheDocument();
|
|
164
|
+
|
|
165
|
+
await user.clear(input);
|
|
166
|
+
await user.type(input, '445566');
|
|
167
|
+
await user.tab();
|
|
168
|
+
|
|
169
|
+
expect(input).toHaveValue('445566');
|
|
170
|
+
expect(
|
|
171
|
+
screen.getByTestId('ucl-uikit-input-formatted-value')
|
|
172
|
+
).toHaveTextContent('44-55-66');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('formats a value with a function', () => {
|
|
176
|
+
render(
|
|
177
|
+
<ThemeContextProvider>
|
|
178
|
+
<Input
|
|
179
|
+
value='1234.5'
|
|
180
|
+
format={(value) => `£${value}`}
|
|
181
|
+
onChange={() => {}}
|
|
182
|
+
/>
|
|
183
|
+
</ThemeContextProvider>
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
expect(
|
|
187
|
+
screen.getByTestId('ucl-uikit-input-formatted-value')
|
|
188
|
+
).toHaveTextContent('£1234.5');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('submits the raw value when formatted', () => {
|
|
192
|
+
render(
|
|
193
|
+
<ThemeContextProvider>
|
|
194
|
+
<form data-testid='form'>
|
|
195
|
+
<Input
|
|
196
|
+
name='sortCode'
|
|
197
|
+
defaultValue='112233'
|
|
198
|
+
format='##-##-##'
|
|
199
|
+
/>
|
|
200
|
+
</form>
|
|
201
|
+
</ThemeContextProvider>
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const form = screen.getByTestId('form') as HTMLFormElement;
|
|
205
|
+
expect(new FormData(form).get('sortCode')).toBe('112233');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('resynchronises a formatted value after form reset', async () => {
|
|
209
|
+
const user = userEvent.setup();
|
|
210
|
+
render(
|
|
211
|
+
<ThemeContextProvider>
|
|
212
|
+
<form data-testid='form'>
|
|
213
|
+
<Input
|
|
214
|
+
defaultValue='112233'
|
|
215
|
+
format='##-##-##'
|
|
216
|
+
/>
|
|
217
|
+
</form>
|
|
218
|
+
</ThemeContextProvider>
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const input = screen.getByTestId('ucl-uikit-input');
|
|
222
|
+
const form = screen.getByTestId('form') as HTMLFormElement;
|
|
223
|
+
await user.click(input);
|
|
224
|
+
await user.clear(input);
|
|
225
|
+
await user.type(input, '445566');
|
|
226
|
+
await user.tab();
|
|
227
|
+
|
|
228
|
+
form.reset();
|
|
229
|
+
|
|
230
|
+
await waitFor(() => {
|
|
231
|
+
expect(input).toHaveValue('112233');
|
|
232
|
+
expect(
|
|
233
|
+
screen.getByTestId('ucl-uikit-input-formatted-value')
|
|
234
|
+
).toHaveTextContent('11-22-33');
|
|
235
|
+
});
|
|
236
|
+
});
|
|
142
237
|
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type MaskFormat = string | ((value: string) => string);
|
|
2
|
+
|
|
3
|
+
const applyMaskTemplate = (value: string, template: string) => {
|
|
4
|
+
const valueCharacters = Array.from(value);
|
|
5
|
+
let valueIndex = 0;
|
|
6
|
+
let maskedValue = '';
|
|
7
|
+
|
|
8
|
+
for (const templateCharacter of Array.from(template)) {
|
|
9
|
+
if (valueIndex >= valueCharacters.length) break;
|
|
10
|
+
|
|
11
|
+
if (templateCharacter === '#') {
|
|
12
|
+
maskedValue += valueCharacters[valueIndex];
|
|
13
|
+
valueIndex += 1;
|
|
14
|
+
} else if (templateCharacter === '*') {
|
|
15
|
+
maskedValue += '*';
|
|
16
|
+
valueIndex += 1;
|
|
17
|
+
} else {
|
|
18
|
+
maskedValue += templateCharacter;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
maskedValue + '*'.repeat(Math.max(0, valueCharacters.length - valueIndex))
|
|
24
|
+
);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const formatConfidentialValue = (value: string, maskFormat: MaskFormat) => {
|
|
28
|
+
const maskedValue =
|
|
29
|
+
typeof maskFormat === 'function'
|
|
30
|
+
? maskFormat(value)
|
|
31
|
+
: applyMaskTemplate(value, maskFormat);
|
|
32
|
+
|
|
33
|
+
return maskedValue.split('*').join('•');
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export default formatConfidentialValue;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type DisplayValueFormat = string | ((value: string) => string);
|
|
2
|
+
|
|
3
|
+
const applyFormatTemplate = (value: string, template: string) => {
|
|
4
|
+
const valueCharacters = Array.from(value);
|
|
5
|
+
let valueIndex = 0;
|
|
6
|
+
let formattedValue = '';
|
|
7
|
+
|
|
8
|
+
for (const templateCharacter of Array.from(template)) {
|
|
9
|
+
if (valueIndex >= valueCharacters.length) break;
|
|
10
|
+
|
|
11
|
+
if (templateCharacter === '#') {
|
|
12
|
+
formattedValue += valueCharacters[valueIndex];
|
|
13
|
+
valueIndex += 1;
|
|
14
|
+
} else {
|
|
15
|
+
formattedValue += templateCharacter;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return formattedValue + valueCharacters.slice(valueIndex).join('');
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const formatDisplayValue = (value: string, format?: DisplayValueFormat) => {
|
|
23
|
+
if (!format) return value;
|
|
24
|
+
return typeof format === 'function'
|
|
25
|
+
? format(value)
|
|
26
|
+
: applyFormatTemplate(value, format);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export default formatDisplayValue;
|
package/lib/components/index.ts
CHANGED
|
@@ -7,6 +7,12 @@ export type { InputProps } from './Input';
|
|
|
7
7
|
export { default as ConfidentialInput } from './ConfidentialInput';
|
|
8
8
|
export type { ConfidentialInputProps } from './ConfidentialInput';
|
|
9
9
|
|
|
10
|
+
export { default as DisplayField } from './DisplayField';
|
|
11
|
+
export type { DisplayFieldProps } from './DisplayField';
|
|
12
|
+
|
|
13
|
+
export { default as ConfidentialDisplayField } from './ConfidentialDisplayField';
|
|
14
|
+
export type { ConfidentialDisplayFieldProps } from './ConfidentialDisplayField';
|
|
15
|
+
|
|
10
16
|
export { default as Link } from './Link';
|
|
11
17
|
export type { LinkProps } from './Link';
|
|
12
18
|
|