uikit-react-public 0.47.2 → 0.48.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/ConfidentialInput/ConfidentialInput.d.ts +10 -0
- package/dist/components/ConfidentialInput/ConfidentialInput.stories.d.ts +21 -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 +4588 -4432
- package/lib/components/ConfidentialInput/ConfidentialInput.stories.tsx +51 -0
- package/lib/components/ConfidentialInput/ConfidentialInput.tsx +280 -0
- package/lib/components/ConfidentialInput/Documentation.mdx +70 -0
- package/lib/components/ConfidentialInput/__tests__/ConfidentialInput.test.tsx +339 -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/index.ts +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
};
|
|
46
|
+
|
|
47
|
+
export const MaskedWhenFocused: Story = {
|
|
48
|
+
args: {
|
|
49
|
+
visibleWhenFocused: false,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
@@ -0,0 +1,280 @@
|
|
|
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
|
+
visibleWhenFocused?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type Ref = HTMLInputElement;
|
|
32
|
+
|
|
33
|
+
const getStringValue = (
|
|
34
|
+
value: InputProps['value'] | InputProps['defaultValue']
|
|
35
|
+
) => {
|
|
36
|
+
if (value === undefined || value === null) return '';
|
|
37
|
+
return Array.isArray(value) ? value.join(',') : String(value);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const applyMaskTemplate = (value: string, template: string) => {
|
|
41
|
+
const valueCharacters = Array.from(value);
|
|
42
|
+
let valueIndex = 0;
|
|
43
|
+
let maskedValue = '';
|
|
44
|
+
|
|
45
|
+
for (const templateCharacter of Array.from(template)) {
|
|
46
|
+
if (valueIndex >= valueCharacters.length) break;
|
|
47
|
+
|
|
48
|
+
if (templateCharacter === '1') {
|
|
49
|
+
maskedValue += valueCharacters[valueIndex];
|
|
50
|
+
valueIndex += 1;
|
|
51
|
+
} else if (templateCharacter === '*') {
|
|
52
|
+
maskedValue += '*';
|
|
53
|
+
valueIndex += 1;
|
|
54
|
+
} else {
|
|
55
|
+
maskedValue += templateCharacter;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
maskedValue + '*'.repeat(Math.max(0, valueCharacters.length - valueIndex))
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const ConfidentialInput = forwardRef<Ref, ConfidentialInputProps>(
|
|
65
|
+
(
|
|
66
|
+
{
|
|
67
|
+
maskFormat,
|
|
68
|
+
showable = false,
|
|
69
|
+
visibleWhenFocused = true,
|
|
70
|
+
value,
|
|
71
|
+
defaultValue,
|
|
72
|
+
disabled,
|
|
73
|
+
icon,
|
|
74
|
+
iconPosition = 'left',
|
|
75
|
+
inputClassName,
|
|
76
|
+
className,
|
|
77
|
+
testId = NAME,
|
|
78
|
+
onChange,
|
|
79
|
+
onBlur,
|
|
80
|
+
onFocus,
|
|
81
|
+
m,
|
|
82
|
+
mv,
|
|
83
|
+
mh,
|
|
84
|
+
mt,
|
|
85
|
+
mb,
|
|
86
|
+
ml,
|
|
87
|
+
mr,
|
|
88
|
+
noMargins,
|
|
89
|
+
...props
|
|
90
|
+
},
|
|
91
|
+
ref
|
|
92
|
+
) => {
|
|
93
|
+
const [theme] = useTheme();
|
|
94
|
+
const { disabled: contextDisabled } = use(FieldContext);
|
|
95
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
96
|
+
const [isShown, setIsShown] = useState(false);
|
|
97
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
98
|
+
const [uncontrolledValue, setUncontrolledValue] = useState(() =>
|
|
99
|
+
getStringValue(defaultValue)
|
|
100
|
+
);
|
|
101
|
+
const resolvedDisabled = disabled ?? contextDisabled;
|
|
102
|
+
const valueIsPersistentlyShown = showable && isShown;
|
|
103
|
+
const valueIsVisible =
|
|
104
|
+
(visibleWhenFocused && isFocused) || valueIsPersistentlyShown;
|
|
105
|
+
|
|
106
|
+
const setInputRef = useCallback(
|
|
107
|
+
(element: HTMLInputElement | null) => {
|
|
108
|
+
inputRef.current = element;
|
|
109
|
+
|
|
110
|
+
if (typeof ref === 'function') {
|
|
111
|
+
ref(element);
|
|
112
|
+
} else if (ref) {
|
|
113
|
+
ref.current = element;
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
[ref]
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (!showable) setIsShown(false);
|
|
121
|
+
}, [showable]);
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
const input = inputRef.current;
|
|
125
|
+
const form = input?.form;
|
|
126
|
+
if (!input || !form || value !== undefined) return;
|
|
127
|
+
|
|
128
|
+
let resetTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
129
|
+
const handleReset = () => {
|
|
130
|
+
if (resetTimeout) clearTimeout(resetTimeout);
|
|
131
|
+
resetTimeout = setTimeout(() => {
|
|
132
|
+
setUncontrolledValue(input.value);
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
form.addEventListener('reset', handleReset);
|
|
137
|
+
return () => {
|
|
138
|
+
form.removeEventListener('reset', handleReset);
|
|
139
|
+
if (resetTimeout) clearTimeout(resetTimeout);
|
|
140
|
+
};
|
|
141
|
+
}, [value]);
|
|
142
|
+
|
|
143
|
+
const stringValue =
|
|
144
|
+
value === undefined ? uncontrolledValue : getStringValue(value);
|
|
145
|
+
const maskedValue =
|
|
146
|
+
typeof maskFormat === 'function'
|
|
147
|
+
? maskFormat(stringValue)
|
|
148
|
+
: applyMaskTemplate(stringValue, maskFormat);
|
|
149
|
+
const displayedMaskedValue = maskedValue.split('*').join('•');
|
|
150
|
+
const configuredMaskIsVisible =
|
|
151
|
+
!valueIsPersistentlyShown && !isFocused && stringValue.length > 0;
|
|
152
|
+
|
|
153
|
+
const wrapperStyle = cx(
|
|
154
|
+
NAME,
|
|
155
|
+
css`
|
|
156
|
+
position: relative;
|
|
157
|
+
display: block;
|
|
158
|
+
`,
|
|
159
|
+
marginsStyle({ m, mv, mh, mt, mb, ml, mr, noMargins }, theme),
|
|
160
|
+
className
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const hiddenInputStyle = css`
|
|
164
|
+
color: transparent;
|
|
165
|
+
-webkit-text-fill-color: transparent;
|
|
166
|
+
caret-color: ${theme.colour.text.default};
|
|
167
|
+
`;
|
|
168
|
+
|
|
169
|
+
const { md } = theme.typography.body;
|
|
170
|
+
const maskStyle = css`
|
|
171
|
+
position: absolute;
|
|
172
|
+
top: 0;
|
|
173
|
+
bottom: 0;
|
|
174
|
+
left: ${
|
|
175
|
+
icon && iconPosition === 'left' ? theme.padding.p48 : theme.padding.p16
|
|
176
|
+
};
|
|
177
|
+
right: ${
|
|
178
|
+
showable && icon && iconPosition === 'right'
|
|
179
|
+
? theme.padding.p80
|
|
180
|
+
: showable || (icon && iconPosition === 'right')
|
|
181
|
+
? theme.padding.p48
|
|
182
|
+
: theme.padding.p16
|
|
183
|
+
};
|
|
184
|
+
display: flex;
|
|
185
|
+
align-items: center;
|
|
186
|
+
overflow: hidden;
|
|
187
|
+
color: ${
|
|
188
|
+
resolvedDisabled
|
|
189
|
+
? theme.colour.text.disabled
|
|
190
|
+
: theme.colour.text.default
|
|
191
|
+
};
|
|
192
|
+
font-family: ${md.fontFamily};
|
|
193
|
+
font-feature-settings: ${md.fontSettings};
|
|
194
|
+
font-size: ${md.fontSize}px;
|
|
195
|
+
font-weight: ${md.fontWeight};
|
|
196
|
+
line-height: ${md.lineHeight}%;
|
|
197
|
+
pointer-events: none;
|
|
198
|
+
white-space: nowrap;
|
|
199
|
+
`;
|
|
200
|
+
|
|
201
|
+
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
202
|
+
if (value === undefined) {
|
|
203
|
+
setUncontrolledValue(event.target.value);
|
|
204
|
+
}
|
|
205
|
+
onChange?.(event);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const handleFocus = (event: FocusEvent<HTMLInputElement>) => {
|
|
209
|
+
setIsFocused(true);
|
|
210
|
+
onFocus?.(event);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
|
|
214
|
+
setIsFocused(false);
|
|
215
|
+
onBlur?.(event);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const visibilityButton = showable ? (
|
|
219
|
+
<IconButton
|
|
220
|
+
type='button'
|
|
221
|
+
disabled={resolvedDisabled}
|
|
222
|
+
aria-label={
|
|
223
|
+
valueIsPersistentlyShown
|
|
224
|
+
? 'Hide confidential value'
|
|
225
|
+
: 'Show confidential value'
|
|
226
|
+
}
|
|
227
|
+
aria-pressed={valueIsPersistentlyShown}
|
|
228
|
+
testId={`${testId}-visibility-toggle`}
|
|
229
|
+
onClick={() => setIsShown((shown) => !shown)}
|
|
230
|
+
>
|
|
231
|
+
{valueIsPersistentlyShown ? (
|
|
232
|
+
<Icon.EyeOff
|
|
233
|
+
aria-hidden='true'
|
|
234
|
+
testId={`${testId}-hide-icon`}
|
|
235
|
+
/>
|
|
236
|
+
) : (
|
|
237
|
+
<Icon.Eye
|
|
238
|
+
aria-hidden='true'
|
|
239
|
+
testId={`${testId}-show-icon`}
|
|
240
|
+
/>
|
|
241
|
+
)}
|
|
242
|
+
</IconButton>
|
|
243
|
+
) : undefined;
|
|
244
|
+
|
|
245
|
+
return (
|
|
246
|
+
<span className={wrapperStyle}>
|
|
247
|
+
<Input
|
|
248
|
+
{...props}
|
|
249
|
+
ref={setInputRef}
|
|
250
|
+
value={value}
|
|
251
|
+
defaultValue={defaultValue}
|
|
252
|
+
disabled={resolvedDisabled}
|
|
253
|
+
icon={icon}
|
|
254
|
+
iconPosition={iconPosition}
|
|
255
|
+
iconButton={visibilityButton}
|
|
256
|
+
inputClassName={cx(
|
|
257
|
+
configuredMaskIsVisible && hiddenInputStyle,
|
|
258
|
+
inputClassName
|
|
259
|
+
)}
|
|
260
|
+
testId={testId}
|
|
261
|
+
type={valueIsVisible ? 'text' : 'password'}
|
|
262
|
+
onChange={handleChange}
|
|
263
|
+
onFocus={handleFocus}
|
|
264
|
+
onBlur={handleBlur}
|
|
265
|
+
/>
|
|
266
|
+
{configuredMaskIsVisible && (
|
|
267
|
+
<span
|
|
268
|
+
className={maskStyle}
|
|
269
|
+
data-testid={`${testId}-mask`}
|
|
270
|
+
aria-hidden='true'
|
|
271
|
+
>
|
|
272
|
+
{displayedMaskedValue}
|
|
273
|
+
</span>
|
|
274
|
+
)}
|
|
275
|
+
</span>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
export default memo(ConfidentialInput);
|
|
@@ -0,0 +1,70 @@
|
|
|
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 with a `•`, and all other characters are displayed as
|
|
24
|
+
separators. Asterisks returned by a formatter function are also displayed as
|
|
25
|
+
bullets. Focusing the input temporarily reveals the original value for editing,
|
|
26
|
+
and blurring restores the configured mask. When `showable` is `true`, the Eye
|
|
27
|
+
control lets users keep the original value visible while the input is
|
|
28
|
+
unfocused. Set `visibleWhenFocused` to `false` to use the browser's native
|
|
29
|
+
password mask while editing; this keeps the caret aligned with the underlying
|
|
30
|
+
value. The configured mask returns on blur.
|
|
31
|
+
|
|
32
|
+
<Source
|
|
33
|
+
code={`<ConfidentialInput
|
|
34
|
+
defaultValue='12345678'
|
|
35
|
+
maskFormat='****1111'
|
|
36
|
+
showable
|
|
37
|
+
aria-label='Account number'
|
|
38
|
+
/>`}
|
|
39
|
+
/>
|
|
40
|
+
|
|
41
|
+
A function can be supplied when the template syntax is not sufficient:
|
|
42
|
+
|
|
43
|
+
<Source
|
|
44
|
+
code={`<ConfidentialInput
|
|
45
|
+
defaultValue='12345678'
|
|
46
|
+
maskFormat={(value) =>
|
|
47
|
+
\`\${'*'.repeat(Math.max(0, value.length - 2))}\${value.slice(-2)}\`
|
|
48
|
+
}
|
|
49
|
+
/>`}
|
|
50
|
+
/>
|
|
51
|
+
|
|
52
|
+
## Props
|
|
53
|
+
|
|
54
|
+
- `maskFormat`: **string | (value: string) => string** - Required mask template
|
|
55
|
+
or formatter.
|
|
56
|
+
- `showable`: **boolean** - Adds a button that toggles the original value.
|
|
57
|
+
Defaults to `false`.
|
|
58
|
+
- `visibleWhenFocused`: **boolean** - Reveals the original value while focused.
|
|
59
|
+
When `false`, the native password mask is shown while editing. Defaults to
|
|
60
|
+
`true`.
|
|
61
|
+
- All `Input` props except `type` and `iconButton` are supported.
|
|
62
|
+
|
|
63
|
+
<ArgTypes />
|
|
64
|
+
|
|
65
|
+
## Examples
|
|
66
|
+
|
|
67
|
+
<Canvas of={ConfidentialInputStories.Showable} />
|
|
68
|
+
<Canvas of={ConfidentialInputStories.WithSeparators} />
|
|
69
|
+
<Canvas of={ConfidentialInputStories.FunctionMask} />
|
|
70
|
+
<Canvas of={ConfidentialInputStories.MaskedWhenFocused} />
|