uikit-react-public 0.47.1 → 0.48.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/dist/components/ConfidentialInput/ConfidentialInput.d.ts +9 -0
- package/dist/components/ConfidentialInput/ConfidentialInput.stories.d.ts +20 -0
- package/dist/components/ConfidentialInput/__tests__/ConfidentialInput.test.d.ts +1 -0
- package/dist/components/ConfidentialInput/index.d.ts +2 -0
- package/dist/components/index.d.ts +2 -0
- package/dist/index.js +4620 -4463
- package/lib/components/ConfidentialInput/ConfidentialInput.stories.tsx +45 -0
- package/lib/components/ConfidentialInput/ConfidentialInput.tsx +273 -0
- package/lib/components/ConfidentialInput/Documentation.mdx +62 -0
- package/lib/components/ConfidentialInput/__tests__/ConfidentialInput.test.tsx +272 -0
- package/lib/components/ConfidentialInput/index.ts +2 -0
- package/lib/components/Input/Input.tsx +18 -2
- package/lib/components/Input/__tests__/Input.test.tsx +34 -0
- package/lib/components/Label/Label.tsx +8 -2
- package/lib/components/Label/__tests__/__snapshots__/Label.test.tsx.snap +3 -1
- package/lib/components/index.ts +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import ConfidentialInput from './ConfidentialInput';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Components/ConfidentialInput',
|
|
6
|
+
component: ConfidentialInput,
|
|
7
|
+
parameters: {
|
|
8
|
+
layout: 'centered',
|
|
9
|
+
},
|
|
10
|
+
tags: ['autodocs'],
|
|
11
|
+
args: {
|
|
12
|
+
'aria-label': 'Account number',
|
|
13
|
+
defaultValue: '12345678',
|
|
14
|
+
maskFormat: '****1111',
|
|
15
|
+
},
|
|
16
|
+
} satisfies Meta<typeof ConfidentialInput>;
|
|
17
|
+
|
|
18
|
+
export default meta;
|
|
19
|
+
|
|
20
|
+
type Story = StoryObj<typeof meta>;
|
|
21
|
+
|
|
22
|
+
export const Default: Story = {};
|
|
23
|
+
|
|
24
|
+
export const Showable: Story = {
|
|
25
|
+
args: {
|
|
26
|
+
showable: true,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const WithSeparators: Story = {
|
|
31
|
+
args: {
|
|
32
|
+
'aria-label': 'Sort code',
|
|
33
|
+
defaultValue: '123472',
|
|
34
|
+
maskFormat: '**-**-11',
|
|
35
|
+
showable: true,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const FunctionMask: Story = {
|
|
40
|
+
args: {
|
|
41
|
+
maskFormat: (value) =>
|
|
42
|
+
`${'*'.repeat(Math.max(0, value.length - 2))}${value.slice(-2)}`,
|
|
43
|
+
showable: true,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeEvent,
|
|
3
|
+
FocusEvent,
|
|
4
|
+
forwardRef,
|
|
5
|
+
memo,
|
|
6
|
+
use,
|
|
7
|
+
useCallback,
|
|
8
|
+
useEffect,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
} from 'react';
|
|
12
|
+
import { css, cx } from '@emotion/css';
|
|
13
|
+
import useTheme from '../../theme/useTheme';
|
|
14
|
+
import Icon from '../Icon';
|
|
15
|
+
import IconButton from '../IconButton';
|
|
16
|
+
import Input, { InputProps } from '../Input';
|
|
17
|
+
import { FieldContext } from '../Field';
|
|
18
|
+
import marginsStyle from '../common/marginsStyle';
|
|
19
|
+
|
|
20
|
+
export const NAME = 'ucl-uikit-confidential-input';
|
|
21
|
+
|
|
22
|
+
export interface ConfidentialInputProps extends Omit<
|
|
23
|
+
InputProps,
|
|
24
|
+
'iconButton' | 'type'
|
|
25
|
+
> {
|
|
26
|
+
maskFormat: string | ((value: string) => string);
|
|
27
|
+
showable?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type Ref = HTMLInputElement;
|
|
31
|
+
|
|
32
|
+
const getStringValue = (
|
|
33
|
+
value: InputProps['value'] | InputProps['defaultValue']
|
|
34
|
+
) => {
|
|
35
|
+
if (value === undefined || value === null) return '';
|
|
36
|
+
return Array.isArray(value) ? value.join(',') : String(value);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const applyMaskTemplate = (value: string, template: string) => {
|
|
40
|
+
const valueCharacters = Array.from(value);
|
|
41
|
+
let valueIndex = 0;
|
|
42
|
+
let maskedValue = '';
|
|
43
|
+
|
|
44
|
+
for (const templateCharacter of Array.from(template)) {
|
|
45
|
+
if (valueIndex >= valueCharacters.length) break;
|
|
46
|
+
|
|
47
|
+
if (templateCharacter === '1') {
|
|
48
|
+
maskedValue += valueCharacters[valueIndex];
|
|
49
|
+
valueIndex += 1;
|
|
50
|
+
} else if (templateCharacter === '*') {
|
|
51
|
+
maskedValue += '*';
|
|
52
|
+
valueIndex += 1;
|
|
53
|
+
} else {
|
|
54
|
+
maskedValue += templateCharacter;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
maskedValue + '*'.repeat(Math.max(0, valueCharacters.length - valueIndex))
|
|
60
|
+
);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const ConfidentialInput = forwardRef<Ref, ConfidentialInputProps>(
|
|
64
|
+
(
|
|
65
|
+
{
|
|
66
|
+
maskFormat,
|
|
67
|
+
showable = false,
|
|
68
|
+
value,
|
|
69
|
+
defaultValue,
|
|
70
|
+
disabled,
|
|
71
|
+
icon,
|
|
72
|
+
iconPosition = 'left',
|
|
73
|
+
inputClassName,
|
|
74
|
+
className,
|
|
75
|
+
testId = NAME,
|
|
76
|
+
onChange,
|
|
77
|
+
onBlur,
|
|
78
|
+
onFocus,
|
|
79
|
+
m,
|
|
80
|
+
mv,
|
|
81
|
+
mh,
|
|
82
|
+
mt,
|
|
83
|
+
mb,
|
|
84
|
+
ml,
|
|
85
|
+
mr,
|
|
86
|
+
noMargins,
|
|
87
|
+
...props
|
|
88
|
+
},
|
|
89
|
+
ref
|
|
90
|
+
) => {
|
|
91
|
+
const [theme] = useTheme();
|
|
92
|
+
const { disabled: contextDisabled } = use(FieldContext);
|
|
93
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
94
|
+
const [isShown, setIsShown] = useState(false);
|
|
95
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
96
|
+
const [uncontrolledValue, setUncontrolledValue] = useState(() =>
|
|
97
|
+
getStringValue(defaultValue)
|
|
98
|
+
);
|
|
99
|
+
const resolvedDisabled = disabled ?? contextDisabled;
|
|
100
|
+
const valueIsShown = showable && isShown;
|
|
101
|
+
|
|
102
|
+
const setInputRef = useCallback(
|
|
103
|
+
(element: HTMLInputElement | null) => {
|
|
104
|
+
inputRef.current = element;
|
|
105
|
+
|
|
106
|
+
if (typeof ref === 'function') {
|
|
107
|
+
ref(element);
|
|
108
|
+
} else if (ref) {
|
|
109
|
+
ref.current = element;
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
[ref]
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (!showable) setIsShown(false);
|
|
117
|
+
}, [showable]);
|
|
118
|
+
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
const input = inputRef.current;
|
|
121
|
+
const form = input?.form;
|
|
122
|
+
if (!input || !form || value !== undefined) return;
|
|
123
|
+
|
|
124
|
+
let resetTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
125
|
+
const handleReset = () => {
|
|
126
|
+
if (resetTimeout) clearTimeout(resetTimeout);
|
|
127
|
+
resetTimeout = setTimeout(() => {
|
|
128
|
+
setUncontrolledValue(input.value);
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
form.addEventListener('reset', handleReset);
|
|
133
|
+
return () => {
|
|
134
|
+
form.removeEventListener('reset', handleReset);
|
|
135
|
+
if (resetTimeout) clearTimeout(resetTimeout);
|
|
136
|
+
};
|
|
137
|
+
}, [value]);
|
|
138
|
+
|
|
139
|
+
const stringValue =
|
|
140
|
+
value === undefined ? uncontrolledValue : getStringValue(value);
|
|
141
|
+
const maskedValue =
|
|
142
|
+
typeof maskFormat === 'function'
|
|
143
|
+
? maskFormat(stringValue)
|
|
144
|
+
: applyMaskTemplate(stringValue, maskFormat);
|
|
145
|
+
const visuallyHidden =
|
|
146
|
+
!valueIsShown && !isFocused && stringValue.length > 0;
|
|
147
|
+
|
|
148
|
+
const wrapperStyle = cx(
|
|
149
|
+
NAME,
|
|
150
|
+
css`
|
|
151
|
+
position: relative;
|
|
152
|
+
display: block;
|
|
153
|
+
`,
|
|
154
|
+
marginsStyle({ m, mv, mh, mt, mb, ml, mr, noMargins }, theme),
|
|
155
|
+
className
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const hiddenInputStyle = css`
|
|
159
|
+
color: transparent;
|
|
160
|
+
-webkit-text-fill-color: transparent;
|
|
161
|
+
caret-color: ${theme.colour.text.default};
|
|
162
|
+
`;
|
|
163
|
+
|
|
164
|
+
const { md } = theme.typography.body;
|
|
165
|
+
const maskStyle = css`
|
|
166
|
+
position: absolute;
|
|
167
|
+
top: 0;
|
|
168
|
+
bottom: 0;
|
|
169
|
+
left: ${
|
|
170
|
+
icon && iconPosition === 'left' ? theme.padding.p48 : theme.padding.p16
|
|
171
|
+
};
|
|
172
|
+
right: ${
|
|
173
|
+
showable && icon && iconPosition === 'right'
|
|
174
|
+
? theme.padding.p80
|
|
175
|
+
: showable || (icon && iconPosition === 'right')
|
|
176
|
+
? theme.padding.p48
|
|
177
|
+
: theme.padding.p16
|
|
178
|
+
};
|
|
179
|
+
display: flex;
|
|
180
|
+
align-items: center;
|
|
181
|
+
overflow: hidden;
|
|
182
|
+
color: ${
|
|
183
|
+
resolvedDisabled
|
|
184
|
+
? theme.colour.text.disabled
|
|
185
|
+
: theme.colour.text.default
|
|
186
|
+
};
|
|
187
|
+
font-family: ${md.fontFamily};
|
|
188
|
+
font-feature-settings: ${md.fontSettings};
|
|
189
|
+
font-size: ${md.fontSize}px;
|
|
190
|
+
font-weight: ${md.fontWeight};
|
|
191
|
+
line-height: ${md.lineHeight}%;
|
|
192
|
+
pointer-events: none;
|
|
193
|
+
white-space: nowrap;
|
|
194
|
+
`;
|
|
195
|
+
|
|
196
|
+
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
197
|
+
if (value === undefined) {
|
|
198
|
+
setUncontrolledValue(event.target.value);
|
|
199
|
+
}
|
|
200
|
+
onChange?.(event);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const handleFocus = (event: FocusEvent<HTMLInputElement>) => {
|
|
204
|
+
setIsFocused(true);
|
|
205
|
+
onFocus?.(event);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
|
|
209
|
+
setIsFocused(false);
|
|
210
|
+
onBlur?.(event);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const visibilityButton = showable ? (
|
|
214
|
+
<IconButton
|
|
215
|
+
type='button'
|
|
216
|
+
disabled={resolvedDisabled}
|
|
217
|
+
aria-label={
|
|
218
|
+
valueIsShown ? 'Hide confidential value' : 'Show confidential value'
|
|
219
|
+
}
|
|
220
|
+
aria-pressed={valueIsShown}
|
|
221
|
+
testId={`${testId}-visibility-toggle`}
|
|
222
|
+
onClick={() => setIsShown((shown) => !shown)}
|
|
223
|
+
>
|
|
224
|
+
{valueIsShown ? (
|
|
225
|
+
<Icon.EyeOff
|
|
226
|
+
aria-hidden='true'
|
|
227
|
+
testId={`${testId}-hide-icon`}
|
|
228
|
+
/>
|
|
229
|
+
) : (
|
|
230
|
+
<Icon.Eye
|
|
231
|
+
aria-hidden='true'
|
|
232
|
+
testId={`${testId}-show-icon`}
|
|
233
|
+
/>
|
|
234
|
+
)}
|
|
235
|
+
</IconButton>
|
|
236
|
+
) : undefined;
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<span className={wrapperStyle}>
|
|
240
|
+
<Input
|
|
241
|
+
{...props}
|
|
242
|
+
ref={setInputRef}
|
|
243
|
+
value={value}
|
|
244
|
+
defaultValue={defaultValue}
|
|
245
|
+
disabled={resolvedDisabled}
|
|
246
|
+
icon={icon}
|
|
247
|
+
iconPosition={iconPosition}
|
|
248
|
+
iconButton={visibilityButton}
|
|
249
|
+
inputClassName={cx(
|
|
250
|
+
visuallyHidden && hiddenInputStyle,
|
|
251
|
+
inputClassName
|
|
252
|
+
)}
|
|
253
|
+
testId={testId}
|
|
254
|
+
type={valueIsShown ? 'text' : 'password'}
|
|
255
|
+
onChange={handleChange}
|
|
256
|
+
onFocus={handleFocus}
|
|
257
|
+
onBlur={handleBlur}
|
|
258
|
+
/>
|
|
259
|
+
{visuallyHidden && (
|
|
260
|
+
<span
|
|
261
|
+
className={maskStyle}
|
|
262
|
+
data-testid={`${testId}-mask`}
|
|
263
|
+
aria-hidden='true'
|
|
264
|
+
>
|
|
265
|
+
{maskedValue}
|
|
266
|
+
</span>
|
|
267
|
+
)}
|
|
268
|
+
</span>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
export default memo(ConfidentialInput);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import * as ConfidentialInputStories from './ConfidentialInput.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={ConfidentialInputStories} />
|
|
12
|
+
<Title />
|
|
13
|
+
<Subtitle>An Input that masks confidential values</Subtitle>
|
|
14
|
+
|
|
15
|
+
`ConfidentialInput` extends `Input` and preserves the original value for editing,
|
|
16
|
+
change events, and form submission while displaying a masked value.
|
|
17
|
+
|
|
18
|
+
<Canvas of={ConfidentialInputStories.Default} />
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
The string mask is a simple template. `1` reveals the corresponding value
|
|
23
|
+
character, `*` masks it, and all other characters are displayed as separators.
|
|
24
|
+
The configured mask is displayed while the input is unfocused. While editing,
|
|
25
|
+
the native password mask is used so the caret and selection remain aligned with
|
|
26
|
+
the underlying value.
|
|
27
|
+
|
|
28
|
+
<Source
|
|
29
|
+
code={`<ConfidentialInput
|
|
30
|
+
defaultValue='12345678'
|
|
31
|
+
maskFormat='****1111'
|
|
32
|
+
showable
|
|
33
|
+
aria-label='Account number'
|
|
34
|
+
/>`}
|
|
35
|
+
/>
|
|
36
|
+
|
|
37
|
+
A function can be supplied when the template syntax is not sufficient:
|
|
38
|
+
|
|
39
|
+
<Source
|
|
40
|
+
code={`<ConfidentialInput
|
|
41
|
+
defaultValue='12345678'
|
|
42
|
+
maskFormat={(value) =>
|
|
43
|
+
\`\${'*'.repeat(Math.max(0, value.length - 2))}\${value.slice(-2)}\`
|
|
44
|
+
}
|
|
45
|
+
/>`}
|
|
46
|
+
/>
|
|
47
|
+
|
|
48
|
+
## Props
|
|
49
|
+
|
|
50
|
+
- `maskFormat`: **string | (value: string) => string** - Required mask template
|
|
51
|
+
or formatter.
|
|
52
|
+
- `showable`: **boolean** - Adds a button that toggles the original value.
|
|
53
|
+
Defaults to `false`.
|
|
54
|
+
- All `Input` props except `type` and `iconButton` are supported.
|
|
55
|
+
|
|
56
|
+
<ArgTypes />
|
|
57
|
+
|
|
58
|
+
## Examples
|
|
59
|
+
|
|
60
|
+
<Canvas of={ConfidentialInputStories.Showable} />
|
|
61
|
+
<Canvas of={ConfidentialInputStories.WithSeparators} />
|
|
62
|
+
<Canvas of={ConfidentialInputStories.FunctionMask} />
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { createRef } from 'react';
|
|
2
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
3
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
5
|
+
import { ThemeContextProvider } from '../../../theme/useTheme';
|
|
6
|
+
import ConfidentialInput from '../ConfidentialInput';
|
|
7
|
+
import Field from '../../Field';
|
|
8
|
+
import Icon from '../../Icon';
|
|
9
|
+
|
|
10
|
+
const renderInput = (props: React.ComponentProps<typeof ConfidentialInput>) =>
|
|
11
|
+
render(
|
|
12
|
+
<ThemeContextProvider>
|
|
13
|
+
<ConfidentialInput {...props} />
|
|
14
|
+
</ThemeContextProvider>
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
describe('ConfidentialInput', () => {
|
|
18
|
+
test('applies a string mask without changing the input value', () => {
|
|
19
|
+
renderInput({
|
|
20
|
+
maskFormat: '1111****',
|
|
21
|
+
value: '12345678',
|
|
22
|
+
onChange: () => {},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
expect(screen.getByTestId('ucl-uikit-confidential-input')).toHaveValue(
|
|
26
|
+
'12345678'
|
|
27
|
+
);
|
|
28
|
+
expect(
|
|
29
|
+
screen.getByTestId('ucl-uikit-confidential-input-mask')
|
|
30
|
+
).toHaveTextContent('1234****');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('supports separators and masks characters beyond the template', () => {
|
|
34
|
+
renderInput({
|
|
35
|
+
maskFormat: '**-**-11',
|
|
36
|
+
value: '1234729',
|
|
37
|
+
onChange: () => {},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
expect(
|
|
41
|
+
screen.getByTestId('ucl-uikit-confidential-input-mask')
|
|
42
|
+
).toHaveTextContent('**-**-72*');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('applies a function mask', () => {
|
|
46
|
+
renderInput({
|
|
47
|
+
maskFormat: (value) => `ending in ${value.slice(-2)}`,
|
|
48
|
+
value: '12345678',
|
|
49
|
+
onChange: () => {},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(
|
|
53
|
+
screen.getByTestId('ucl-uikit-confidential-input-mask')
|
|
54
|
+
).toHaveTextContent('ending in 78');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('does not add a visibility control by default', () => {
|
|
58
|
+
renderInput({
|
|
59
|
+
maskFormat: '****',
|
|
60
|
+
defaultValue: '1234',
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(
|
|
64
|
+
screen.queryByRole('button', { name: 'Show confidential value' })
|
|
65
|
+
).not.toBeInTheDocument();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('toggles between the masked and shown value', async () => {
|
|
69
|
+
const user = userEvent.setup();
|
|
70
|
+
renderInput({
|
|
71
|
+
maskFormat: '****1111',
|
|
72
|
+
defaultValue: '12345678',
|
|
73
|
+
showable: true,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const input = screen.getByTestId('ucl-uikit-confidential-input');
|
|
77
|
+
const showButton = screen.getByRole('button', {
|
|
78
|
+
name: 'Show confidential value',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(input).toHaveAttribute('type', 'password');
|
|
82
|
+
expect(
|
|
83
|
+
screen.getByTestId('ucl-uikit-confidential-input-show-icon')
|
|
84
|
+
).toBeInTheDocument();
|
|
85
|
+
|
|
86
|
+
await user.click(showButton);
|
|
87
|
+
|
|
88
|
+
expect(input).toHaveAttribute('type', 'text');
|
|
89
|
+
expect(
|
|
90
|
+
screen.queryByTestId('ucl-uikit-confidential-input-mask')
|
|
91
|
+
).not.toBeInTheDocument();
|
|
92
|
+
expect(
|
|
93
|
+
screen.getByRole('button', { name: 'Hide confidential value' })
|
|
94
|
+
).toHaveAttribute('aria-pressed', 'true');
|
|
95
|
+
expect(
|
|
96
|
+
screen.getByTestId('ucl-uikit-confidential-input-hide-icon')
|
|
97
|
+
).toBeInTheDocument();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('updates an uncontrolled value and reports the original value', async () => {
|
|
101
|
+
const user = userEvent.setup();
|
|
102
|
+
const onChange = vi.fn();
|
|
103
|
+
renderInput({
|
|
104
|
+
maskFormat: '****',
|
|
105
|
+
onChange,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const input = screen.getByTestId('ucl-uikit-confidential-input');
|
|
109
|
+
await user.type(input, '1234');
|
|
110
|
+
|
|
111
|
+
expect(input).toHaveValue('1234');
|
|
112
|
+
expect(
|
|
113
|
+
screen.queryByTestId('ucl-uikit-confidential-input-mask')
|
|
114
|
+
).not.toBeInTheDocument();
|
|
115
|
+
|
|
116
|
+
await user.tab();
|
|
117
|
+
|
|
118
|
+
expect(
|
|
119
|
+
screen.getByTestId('ucl-uikit-confidential-input-mask')
|
|
120
|
+
).toHaveTextContent('****');
|
|
121
|
+
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({}));
|
|
122
|
+
expect(onChange.mock.lastCall?.[0].target.value).toBe('1234');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('submits the original value', () => {
|
|
126
|
+
render(
|
|
127
|
+
<ThemeContextProvider>
|
|
128
|
+
<form data-testid='form'>
|
|
129
|
+
<ConfidentialInput
|
|
130
|
+
name='accountNumber'
|
|
131
|
+
maskFormat='****1111'
|
|
132
|
+
defaultValue='12345678'
|
|
133
|
+
/>
|
|
134
|
+
</form>
|
|
135
|
+
</ThemeContextProvider>
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const form = screen.getByTestId('form') as HTMLFormElement;
|
|
139
|
+
expect(new FormData(form).get('accountNumber')).toBe('12345678');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('resynchronises the mask after a native form reset', async () => {
|
|
143
|
+
const user = userEvent.setup();
|
|
144
|
+
render(
|
|
145
|
+
<ThemeContextProvider>
|
|
146
|
+
<form data-testid='form'>
|
|
147
|
+
<ConfidentialInput
|
|
148
|
+
maskFormat={(value) => value}
|
|
149
|
+
defaultValue='1234'
|
|
150
|
+
/>
|
|
151
|
+
</form>
|
|
152
|
+
</ThemeContextProvider>
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const input = screen.getByTestId('ucl-uikit-confidential-input');
|
|
156
|
+
const form = screen.getByTestId('form') as HTMLFormElement;
|
|
157
|
+
|
|
158
|
+
await user.clear(input);
|
|
159
|
+
await user.type(input, '9876');
|
|
160
|
+
|
|
161
|
+
await user.tab();
|
|
162
|
+
|
|
163
|
+
expect(
|
|
164
|
+
screen.getByTestId('ucl-uikit-confidential-input-mask')
|
|
165
|
+
).toHaveTextContent('9876');
|
|
166
|
+
|
|
167
|
+
form.reset();
|
|
168
|
+
|
|
169
|
+
await waitFor(() => {
|
|
170
|
+
expect(input).toHaveValue('1234');
|
|
171
|
+
expect(
|
|
172
|
+
screen.getByTestId('ucl-uikit-confidential-input-mask')
|
|
173
|
+
).toHaveTextContent('1234');
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('uses native password rendering while editing formatted values', async () => {
|
|
178
|
+
const user = userEvent.setup();
|
|
179
|
+
renderInput({
|
|
180
|
+
maskFormat: '**-**-11',
|
|
181
|
+
defaultValue: '123472',
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const input = screen.getByTestId(
|
|
185
|
+
'ucl-uikit-confidential-input'
|
|
186
|
+
) as HTMLInputElement;
|
|
187
|
+
expect(
|
|
188
|
+
screen.getByTestId('ucl-uikit-confidential-input-mask')
|
|
189
|
+
).toHaveTextContent('**-**-72');
|
|
190
|
+
|
|
191
|
+
await user.click(input);
|
|
192
|
+
|
|
193
|
+
expect(input).toHaveAttribute('type', 'password');
|
|
194
|
+
expect(
|
|
195
|
+
screen.queryByTestId('ucl-uikit-confidential-input-mask')
|
|
196
|
+
).not.toBeInTheDocument();
|
|
197
|
+
input.setSelectionRange(4, 4);
|
|
198
|
+
expect(input.selectionStart).toBe(4);
|
|
199
|
+
|
|
200
|
+
await user.tab();
|
|
201
|
+
|
|
202
|
+
expect(
|
|
203
|
+
screen.getByTestId('ucl-uikit-confidential-input-mask')
|
|
204
|
+
).toHaveTextContent('**-**-72');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('supports a right icon alongside the visibility control', () => {
|
|
208
|
+
renderInput({
|
|
209
|
+
maskFormat: '****',
|
|
210
|
+
defaultValue: '1234',
|
|
211
|
+
showable: true,
|
|
212
|
+
icon: <Icon.Search testId='right-icon' />,
|
|
213
|
+
iconPosition: 'right',
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
expect(screen.getByTestId('right-icon').parentElement).toHaveStyle({
|
|
217
|
+
right: '48px',
|
|
218
|
+
});
|
|
219
|
+
expect(
|
|
220
|
+
screen.getByTestId('ucl-uikit-confidential-input-visibility-toggle')
|
|
221
|
+
.parentElement
|
|
222
|
+
).toHaveStyle({ right: '16px' });
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('disables the visibility control with the input', () => {
|
|
226
|
+
renderInput({
|
|
227
|
+
maskFormat: '****',
|
|
228
|
+
defaultValue: '1234',
|
|
229
|
+
showable: true,
|
|
230
|
+
disabled: true,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
expect(
|
|
234
|
+
screen.getByRole('button', { name: 'Show confidential value' })
|
|
235
|
+
).toBeDisabled();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('inherits its disabled state from Field', () => {
|
|
239
|
+
render(
|
|
240
|
+
<ThemeContextProvider>
|
|
241
|
+
<Field disabled>
|
|
242
|
+
<ConfidentialInput
|
|
243
|
+
maskFormat='****'
|
|
244
|
+
defaultValue='1234'
|
|
245
|
+
showable
|
|
246
|
+
/>
|
|
247
|
+
</Field>
|
|
248
|
+
</ThemeContextProvider>
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
expect(screen.getByTestId('ucl-uikit-confidential-input')).toBeDisabled();
|
|
252
|
+
expect(
|
|
253
|
+
screen.getByRole('button', { name: 'Show confidential value' })
|
|
254
|
+
).toBeDisabled();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('forwards its ref to the input', () => {
|
|
258
|
+
const ref = createRef<HTMLInputElement>();
|
|
259
|
+
render(
|
|
260
|
+
<ThemeContextProvider>
|
|
261
|
+
<ConfidentialInput
|
|
262
|
+
ref={ref}
|
|
263
|
+
maskFormat='****'
|
|
264
|
+
/>
|
|
265
|
+
</ThemeContextProvider>
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
expect(ref.current).toBe(
|
|
269
|
+
screen.getByTestId('ucl-uikit-confidential-input')
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -88,13 +88,24 @@ const Input = forwardRef<Ref, InputProps>(
|
|
|
88
88
|
padding-right: ${theme.padding.p48};
|
|
89
89
|
`;
|
|
90
90
|
|
|
91
|
+
const padRightForTwoControlsStyle = css`
|
|
92
|
+
padding-right: ${theme.padding.p80};
|
|
93
|
+
`;
|
|
94
|
+
|
|
95
|
+
const hasRightIconAndButton = Boolean(
|
|
96
|
+
icon && iconPosition === 'right' && iconButton
|
|
97
|
+
);
|
|
98
|
+
|
|
91
99
|
const inputStyle = cx(
|
|
92
100
|
INPUT_NAME,
|
|
93
101
|
inputBaseStyle,
|
|
94
102
|
!disabled && activeAndFocusStyle,
|
|
95
103
|
disabled && disabledStyle,
|
|
96
104
|
icon && iconPosition === 'left' && padLeftStyle,
|
|
97
|
-
((icon && iconPosition === 'right') || iconButton) &&
|
|
105
|
+
((icon && iconPosition === 'right') || iconButton) &&
|
|
106
|
+
!hasRightIconAndButton &&
|
|
107
|
+
padRightStyle,
|
|
108
|
+
hasRightIconAndButton && padRightForTwoControlsStyle,
|
|
98
109
|
inputClassName
|
|
99
110
|
);
|
|
100
111
|
|
|
@@ -126,10 +137,15 @@ const Input = forwardRef<Ref, InputProps>(
|
|
|
126
137
|
right: ${theme.padding.p16};
|
|
127
138
|
`;
|
|
128
139
|
|
|
140
|
+
const rightIconWithButtonPositionStyle = css`
|
|
141
|
+
right: ${theme.padding.p48};
|
|
142
|
+
`;
|
|
143
|
+
|
|
129
144
|
const iconWrapperStyle = cx(
|
|
130
145
|
iconWrapperBaseStyle,
|
|
131
146
|
iconPosition === 'left' && leftPositionStyle,
|
|
132
|
-
iconPosition === 'right' &&
|
|
147
|
+
iconPosition === 'right' &&
|
|
148
|
+
(iconButton ? rightIconWithButtonPositionStyle : rightPositionStyle)
|
|
133
149
|
);
|
|
134
150
|
|
|
135
151
|
const iconButtonStyle = cx(iconWrapperBaseStyle, rightPositionStyle);
|