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,339 @@
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(
77
+ 'ucl-uikit-confidential-input'
78
+ ) as HTMLInputElement;
79
+ const showButton = screen.getByRole('button', {
80
+ name: 'Show confidential value',
81
+ });
82
+
83
+ expect(input).toHaveAttribute('type', 'password');
84
+ expect(
85
+ screen.getByTestId('ucl-uikit-confidential-input-show-icon')
86
+ ).toBeInTheDocument();
87
+
88
+ await user.click(showButton);
89
+
90
+ expect(input).toHaveAttribute('type', 'text');
91
+ expect(
92
+ screen.queryByTestId('ucl-uikit-confidential-input-mask')
93
+ ).not.toBeInTheDocument();
94
+ expect(
95
+ screen.getByRole('button', { name: 'Hide confidential value' })
96
+ ).toHaveAttribute('aria-pressed', 'true');
97
+ expect(
98
+ screen.getByTestId('ucl-uikit-confidential-input-hide-icon')
99
+ ).toBeInTheDocument();
100
+ });
101
+
102
+ test('updates an uncontrolled value and reports the original value', async () => {
103
+ const user = userEvent.setup();
104
+ const onChange = vi.fn();
105
+ renderInput({
106
+ maskFormat: '****',
107
+ onChange,
108
+ });
109
+
110
+ const input = screen.getByTestId('ucl-uikit-confidential-input');
111
+ await user.type(input, '1234');
112
+
113
+ expect(input).toHaveValue('1234');
114
+ expect(input).toHaveAttribute('type', 'text');
115
+ expect(
116
+ screen.queryByTestId('ucl-uikit-confidential-input-mask')
117
+ ).not.toBeInTheDocument();
118
+
119
+ await user.tab();
120
+
121
+ expect(
122
+ screen.getByTestId('ucl-uikit-confidential-input-mask')
123
+ ).toHaveTextContent('••••');
124
+ expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({}));
125
+ expect(onChange.mock.lastCall?.[0].target.value).toBe('1234');
126
+ });
127
+
128
+ test('submits the original value', () => {
129
+ render(
130
+ <ThemeContextProvider>
131
+ <form data-testid='form'>
132
+ <ConfidentialInput
133
+ name='accountNumber'
134
+ maskFormat='****1111'
135
+ defaultValue='12345678'
136
+ />
137
+ </form>
138
+ </ThemeContextProvider>
139
+ );
140
+
141
+ const form = screen.getByTestId('form') as HTMLFormElement;
142
+ expect(new FormData(form).get('accountNumber')).toBe('12345678');
143
+ });
144
+
145
+ test('resynchronises the mask after a native form reset', async () => {
146
+ const user = userEvent.setup();
147
+ render(
148
+ <ThemeContextProvider>
149
+ <form data-testid='form'>
150
+ <ConfidentialInput
151
+ maskFormat={(value) => value}
152
+ defaultValue='1234'
153
+ />
154
+ </form>
155
+ </ThemeContextProvider>
156
+ );
157
+
158
+ const input = screen.getByTestId('ucl-uikit-confidential-input');
159
+ const form = screen.getByTestId('form') as HTMLFormElement;
160
+
161
+ await user.clear(input);
162
+ await user.type(input, '9876');
163
+
164
+ await user.tab();
165
+
166
+ expect(
167
+ screen.getByTestId('ucl-uikit-confidential-input-mask')
168
+ ).toHaveTextContent('9876');
169
+
170
+ form.reset();
171
+
172
+ await waitFor(() => {
173
+ expect(input).toHaveValue('1234');
174
+ expect(
175
+ screen.getByTestId('ucl-uikit-confidential-input-mask')
176
+ ).toHaveTextContent('1234');
177
+ });
178
+ });
179
+
180
+ test('shows the raw value while editing a non-showable value', async () => {
181
+ const user = userEvent.setup();
182
+ renderInput({
183
+ maskFormat: '**-**-11',
184
+ defaultValue: '123472',
185
+ });
186
+
187
+ const input = screen.getByTestId(
188
+ 'ucl-uikit-confidential-input'
189
+ ) as HTMLInputElement;
190
+ expect(
191
+ screen.getByTestId('ucl-uikit-confidential-input-mask')
192
+ ).toHaveTextContent('••-••-72');
193
+
194
+ await user.click(input);
195
+
196
+ expect(input).toHaveAttribute('type', 'text');
197
+ expect(input).toHaveValue('123472');
198
+ expect(
199
+ screen.queryByTestId('ucl-uikit-confidential-input-mask')
200
+ ).not.toBeInTheDocument();
201
+
202
+ await user.tab();
203
+
204
+ expect(
205
+ screen.getByTestId('ucl-uikit-confidential-input-mask')
206
+ ).toHaveTextContent('••-••-72');
207
+ });
208
+
209
+ test('uses native password masking when visibleWhenFocused is false', async () => {
210
+ const user = userEvent.setup();
211
+ renderInput({
212
+ maskFormat: '**-**-11',
213
+ defaultValue: '123472',
214
+ visibleWhenFocused: false,
215
+ });
216
+
217
+ const input = screen.getByTestId(
218
+ 'ucl-uikit-confidential-input'
219
+ ) as HTMLInputElement;
220
+ expect(
221
+ screen.getByTestId('ucl-uikit-confidential-input-mask')
222
+ ).toHaveTextContent('••-••-72');
223
+
224
+ await user.click(input);
225
+
226
+ expect(input).toHaveAttribute('type', 'password');
227
+ expect(
228
+ screen.queryByTestId('ucl-uikit-confidential-input-mask')
229
+ ).not.toBeInTheDocument();
230
+ input.setSelectionRange(4, 4);
231
+ expect(input.selectionStart).toBe(4);
232
+
233
+ await user.tab();
234
+
235
+ expect(
236
+ screen.getByTestId('ucl-uikit-confidential-input-mask')
237
+ ).toHaveTextContent('••-••-72');
238
+ });
239
+
240
+ test('shows the raw value while editing a showable value', async () => {
241
+ const user = userEvent.setup();
242
+ renderInput({
243
+ maskFormat: '**-**-11',
244
+ defaultValue: '123472',
245
+ showable: true,
246
+ });
247
+
248
+ const input = screen.getByTestId(
249
+ 'ucl-uikit-confidential-input'
250
+ ) as HTMLInputElement;
251
+ expect(
252
+ screen.getByTestId('ucl-uikit-confidential-input-mask')
253
+ ).toHaveTextContent('••-••-72');
254
+
255
+ await user.click(input);
256
+
257
+ expect(input).toHaveAttribute('type', 'text');
258
+ expect(
259
+ screen.queryByTestId('ucl-uikit-confidential-input-mask')
260
+ ).not.toBeInTheDocument();
261
+ expect(
262
+ screen.getByRole('button', { name: 'Show confidential value' })
263
+ ).toHaveAttribute('aria-pressed', 'false');
264
+ input.setSelectionRange(4, 4);
265
+ expect(input.selectionStart).toBe(4);
266
+
267
+ await user.tab();
268
+
269
+ expect(
270
+ screen.getByTestId('ucl-uikit-confidential-input-mask')
271
+ ).toHaveTextContent('••-••-72');
272
+ });
273
+
274
+ test('supports a right icon alongside the visibility control', () => {
275
+ renderInput({
276
+ maskFormat: '****',
277
+ defaultValue: '1234',
278
+ showable: true,
279
+ icon: <Icon.Search testId='right-icon' />,
280
+ iconPosition: 'right',
281
+ });
282
+
283
+ expect(screen.getByTestId('right-icon').parentElement).toHaveStyle({
284
+ right: '48px',
285
+ });
286
+ expect(
287
+ screen.getByTestId('ucl-uikit-confidential-input-visibility-toggle')
288
+ .parentElement
289
+ ).toHaveStyle({ right: '16px' });
290
+ });
291
+
292
+ test('disables the visibility control with the input', () => {
293
+ renderInput({
294
+ maskFormat: '****',
295
+ defaultValue: '1234',
296
+ showable: true,
297
+ disabled: true,
298
+ });
299
+
300
+ expect(
301
+ screen.getByRole('button', { name: 'Show confidential value' })
302
+ ).toBeDisabled();
303
+ });
304
+
305
+ test('inherits its disabled state from Field', () => {
306
+ render(
307
+ <ThemeContextProvider>
308
+ <Field disabled>
309
+ <ConfidentialInput
310
+ maskFormat='****'
311
+ defaultValue='1234'
312
+ showable
313
+ />
314
+ </Field>
315
+ </ThemeContextProvider>
316
+ );
317
+
318
+ expect(screen.getByTestId('ucl-uikit-confidential-input')).toBeDisabled();
319
+ expect(
320
+ screen.getByRole('button', { name: 'Show confidential value' })
321
+ ).toBeDisabled();
322
+ });
323
+
324
+ test('forwards its ref to the input', () => {
325
+ const ref = createRef<HTMLInputElement>();
326
+ render(
327
+ <ThemeContextProvider>
328
+ <ConfidentialInput
329
+ ref={ref}
330
+ maskFormat='****'
331
+ />
332
+ </ThemeContextProvider>
333
+ );
334
+
335
+ expect(ref.current).toBe(
336
+ screen.getByTestId('ucl-uikit-confidential-input')
337
+ );
338
+ });
339
+ });
@@ -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);
@@ -3,6 +3,8 @@ import { render, screen } 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';
6
+ import Icon from '../../Icon';
7
+ import IconButton from '../../IconButton';
6
8
 
7
9
  describe('Input', () => {
8
10
  // Snapshot tests
@@ -105,4 +107,36 @@ describe('Input', () => {
105
107
  const input = screen.getByRole('textbox') as HTMLInputElement;
106
108
  expect(input.id).toBe('custom-id');
107
109
  });
110
+
111
+ test('positions a right icon before an icon button', () => {
112
+ render(
113
+ <ThemeContextProvider>
114
+ <Input
115
+ icon={
116
+ <Icon.Search
117
+ testId='right-icon'
118
+ aria-hidden='true'
119
+ />
120
+ }
121
+ iconPosition='right'
122
+ iconButton={
123
+ <IconButton
124
+ testId='icon-button'
125
+ aria-label='Search'
126
+ >
127
+ <Icon.Search />
128
+ </IconButton>
129
+ }
130
+ />
131
+ </ThemeContextProvider>
132
+ );
133
+
134
+ const input = screen.getByTestId('ucl-uikit-input');
135
+ const iconWrapper = screen.getByTestId('right-icon').parentElement;
136
+ const buttonWrapper = screen.getByTestId('icon-button').parentElement;
137
+
138
+ expect(input).toHaveStyle({ paddingRight: '80px' });
139
+ expect(iconWrapper).toHaveStyle({ right: '48px' });
140
+ expect(buttonWrapper).toHaveStyle({ right: '16px' });
141
+ });
108
142
  });
@@ -4,6 +4,9 @@ export type { IconProps } from './Icon';
4
4
  export { default as Input } from './Input';
5
5
  export type { InputProps } from './Input';
6
6
 
7
+ export { default as ConfidentialInput } from './ConfidentialInput';
8
+ export type { ConfidentialInputProps } from './ConfidentialInput';
9
+
7
10
  export { default as Link } from './Link';
8
11
  export type { LinkProps } from './Link';
9
12
 
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "uikit-react-public",
3
3
  "private": false,
4
4
  "license": "UNLICENSED",
5
- "version": "0.47.2",
5
+ "version": "0.48.1",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
8
8
  "types": "dist/index.d.ts",