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.
@@ -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} />