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.
@@ -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
+ });
@@ -0,0 +1,2 @@
1
+ export { default } from './ConfidentialInput';
2
+ export type { ConfidentialInputProps } from './ConfidentialInput';
@@ -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) && padRightStyle,
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' && rightPositionStyle
147
+ iconPosition === 'right' &&
148
+ (iconButton ? rightIconWithButtonPositionStyle : rightPositionStyle)
133
149
  );
134
150
 
135
151
  const iconButtonStyle = cx(iconWrapperBaseStyle, rightPositionStyle);