uikit-react-public 0.49.0 → 0.49.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.
Files changed (25) hide show
  1. package/dist/components/DisplayField/DisplayField.d.ts +3 -0
  2. package/dist/components/DisplayField/DisplayField.stories.d.ts +3 -0
  3. package/dist/components/Input/Input.d.ts +2 -0
  4. package/dist/components/Input/Input.stories.d.ts +1 -0
  5. package/dist/components/common/formatDisplayValue.d.ts +3 -0
  6. package/dist/index.js +4974 -4894
  7. package/lib/components/ConfidentialDisplayField/ConfidentialDisplayField.stories.tsx +3 -2
  8. package/lib/components/ConfidentialDisplayField/ConfidentialDisplayField.tsx +2 -0
  9. package/lib/components/ConfidentialDisplayField/Documentation.mdx +6 -3
  10. package/lib/components/ConfidentialDisplayField/__tests__/ConfidentialDisplayField.test.tsx +25 -6
  11. package/lib/components/ConfidentialInput/ConfidentialInput.stories.tsx +2 -2
  12. package/lib/components/ConfidentialInput/ConfidentialInput.tsx +13 -5
  13. package/lib/components/ConfidentialInput/Documentation.mdx +4 -2
  14. package/lib/components/ConfidentialInput/__tests__/ConfidentialInput.test.tsx +33 -7
  15. package/lib/components/DisplayField/DisplayField.stories.tsx +22 -0
  16. package/lib/components/DisplayField/DisplayField.tsx +20 -8
  17. package/lib/components/DisplayField/Documentation.mdx +9 -0
  18. package/lib/components/DisplayField/__tests__/DisplayField.test.tsx +36 -1
  19. package/lib/components/Input/Documentation.mdx +11 -0
  20. package/lib/components/Input/Input.stories.tsx +7 -0
  21. package/lib/components/Input/Input.tsx +134 -2
  22. package/lib/components/Input/__tests__/Input.test.tsx +96 -1
  23. package/lib/components/common/formatConfidentialValue.ts +1 -1
  24. package/lib/components/common/formatDisplayValue.ts +29 -0
  25. package/package.json +1 -1
@@ -1,8 +1,14 @@
1
1
  import {
2
+ ChangeEvent,
3
+ FocusEvent,
2
4
  memo,
3
5
  InputHTMLAttributes,
4
6
  forwardRef,
5
7
  use,
8
+ useCallback,
9
+ useEffect,
10
+ useRef,
11
+ useState,
6
12
  ReactElement,
7
13
  } from 'react';
8
14
  import useTheme from '../../theme/useTheme';
@@ -11,6 +17,9 @@ import marginsStyle, { MarginProps } from '../common/marginsStyle';
11
17
  import { FieldContext } from '../Field';
12
18
  import { IconProps } from '../Icon';
13
19
  import { IconButtonProps } from '../IconButton/IconButton';
20
+ import formatDisplayValue, {
21
+ DisplayValueFormat,
22
+ } from '../common/formatDisplayValue';
14
23
 
15
24
  export const NAME = 'ucl-uikit-input';
16
25
  export const INPUT_NAME = 'ucl-uikit-input__input';
@@ -20,6 +29,7 @@ export interface InputBaseProps extends InputHTMLAttributes<HTMLInputElement> {
20
29
  iconPosition?: 'left' | 'right';
21
30
  iconButton?: ReactElement<IconButtonProps>;
22
31
  inputClassName?: string;
32
+ format?: DisplayValueFormat;
23
33
  testId?: string;
24
34
  }
25
35
 
@@ -35,17 +45,73 @@ const Input = forwardRef<Ref, InputProps>(
35
45
  iconPosition = 'left',
36
46
  iconButton,
37
47
  inputClassName,
48
+ format,
38
49
  className,
50
+ value,
51
+ defaultValue,
52
+ onChange,
53
+ onFocus,
54
+ onBlur,
39
55
  ...props
40
56
  },
41
- ref
57
+ forwardedRef
42
58
  ) => {
43
59
  const [theme] = useTheme();
44
60
  const { md } = theme.typography.body;
61
+ const inputRef = useRef<HTMLInputElement>(null);
62
+ const [isFocused, setIsFocused] = useState(false);
63
+ const [uncontrolledValue, setUncontrolledValue] = useState(() => {
64
+ if (defaultValue === undefined || defaultValue === null) return '';
65
+ return Array.isArray(defaultValue)
66
+ ? defaultValue.join(',')
67
+ : String(defaultValue);
68
+ });
45
69
 
46
70
  const { disabled: contextDisabled, id: contextId } = use(FieldContext);
47
71
  const disabled = props.disabled ?? contextDisabled;
48
72
  const id = props.id ?? contextId;
73
+ const stringValue =
74
+ value === undefined
75
+ ? uncontrolledValue
76
+ : Array.isArray(value)
77
+ ? value.join(',')
78
+ : String(value);
79
+ const formattedValue = formatDisplayValue(stringValue, format);
80
+ const formattedValueIsVisible =
81
+ Boolean(format) && !isFocused && stringValue.length > 0;
82
+
83
+ const setInputRef = useCallback(
84
+ (element: HTMLInputElement | null) => {
85
+ inputRef.current = element;
86
+
87
+ if (typeof forwardedRef === 'function') {
88
+ forwardedRef(element);
89
+ } else if (forwardedRef) {
90
+ forwardedRef.current = element;
91
+ }
92
+ },
93
+ [forwardedRef]
94
+ );
95
+
96
+ useEffect(() => {
97
+ const input = inputRef.current;
98
+ const form = input?.form;
99
+ if (!input || !form || value !== undefined || !format) return;
100
+
101
+ let resetTimeout: ReturnType<typeof setTimeout> | undefined;
102
+ const handleReset = () => {
103
+ if (resetTimeout) clearTimeout(resetTimeout);
104
+ resetTimeout = setTimeout(() => {
105
+ setUncontrolledValue(input.value);
106
+ });
107
+ };
108
+
109
+ form.addEventListener('reset', handleReset);
110
+ return () => {
111
+ form.removeEventListener('reset', handleReset);
112
+ if (resetTimeout) clearTimeout(resetTimeout);
113
+ };
114
+ }, [format, value]);
49
115
 
50
116
  const inputBaseStyle = css`
51
117
  height: 48px;
@@ -92,6 +158,11 @@ const Input = forwardRef<Ref, InputProps>(
92
158
  padding-right: ${theme.padding.p80};
93
159
  `;
94
160
 
161
+ const formattedInputStyle = css`
162
+ color: transparent;
163
+ -webkit-text-fill-color: transparent;
164
+ `;
165
+
95
166
  const hasRightIconAndButton = Boolean(
96
167
  icon && iconPosition === 'right' && iconButton
97
168
  );
@@ -106,6 +177,7 @@ const Input = forwardRef<Ref, InputProps>(
106
177
  !hasRightIconAndButton &&
107
178
  padRightStyle,
108
179
  hasRightIconAndButton && padRightForTwoControlsStyle,
180
+ formattedValueIsVisible && formattedInputStyle,
109
181
  inputClassName
110
182
  );
111
183
 
@@ -150,16 +222,76 @@ const Input = forwardRef<Ref, InputProps>(
150
222
 
151
223
  const iconButtonStyle = cx(iconWrapperBaseStyle, rightPositionStyle);
152
224
 
225
+ const formattedValueStyle = css`
226
+ position: absolute;
227
+ top: 0;
228
+ bottom: 0;
229
+ left: ${
230
+ icon && iconPosition === 'left' ? theme.padding.p48 : theme.padding.p16
231
+ };
232
+ right: ${
233
+ hasRightIconAndButton
234
+ ? theme.padding.p80
235
+ : (icon && iconPosition === 'right') || iconButton
236
+ ? theme.padding.p48
237
+ : theme.padding.p16
238
+ };
239
+ display: flex;
240
+ align-items: center;
241
+ overflow: hidden;
242
+ color: ${
243
+ disabled ? theme.colour.text.disabled : theme.colour.text.default
244
+ };
245
+ font-family: ${md.fontFamily};
246
+ font-feature-settings: ${md.fontSettings};
247
+ font-size: ${md.fontSize}px;
248
+ font-weight: ${md.fontWeight};
249
+ line-height: ${md.lineHeight}%;
250
+ pointer-events: none;
251
+ white-space: nowrap;
252
+ `;
253
+
254
+ const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
255
+ if (value === undefined) {
256
+ setUncontrolledValue(event.target.value);
257
+ }
258
+ onChange?.(event);
259
+ };
260
+
261
+ const handleFocus = (event: FocusEvent<HTMLInputElement>) => {
262
+ setIsFocused(true);
263
+ onFocus?.(event);
264
+ };
265
+
266
+ const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
267
+ setIsFocused(false);
268
+ onBlur?.(event);
269
+ };
270
+
153
271
  return (
154
272
  <span className={wrapperClass}>
155
273
  <input
156
- ref={ref}
274
+ ref={setInputRef}
157
275
  id={id}
158
276
  className={inputStyle}
159
277
  data-testid={testId}
160
278
  disabled={disabled}
279
+ value={value}
280
+ defaultValue={defaultValue}
281
+ onChange={handleChange}
282
+ onFocus={handleFocus}
283
+ onBlur={handleBlur}
161
284
  {...props}
162
285
  />
286
+ {formattedValueIsVisible && (
287
+ <span
288
+ className={formattedValueStyle}
289
+ data-testid={`${testId}-formatted-value`}
290
+ aria-hidden='true'
291
+ >
292
+ {formattedValue}
293
+ </span>
294
+ )}
163
295
  {icon && <span className={iconWrapperStyle}>{icon}</span>}
164
296
  {iconButton && <span className={iconButtonStyle}>{iconButton}</span>}
165
297
  </span>
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'vitest';
2
- import { render, screen } from '@testing-library/react';
2
+ import { render, screen, waitFor } 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';
@@ -139,4 +139,99 @@ describe('Input', () => {
139
139
  expect(iconWrapper).toHaveStyle({ right: '48px' });
140
140
  expect(buttonWrapper).toHaveStyle({ right: '16px' });
141
141
  });
142
+
143
+ test('formats an uncontrolled value while blurred', async () => {
144
+ const user = userEvent.setup();
145
+ render(
146
+ <ThemeContextProvider>
147
+ <Input
148
+ defaultValue='112233'
149
+ format='##-##-##'
150
+ />
151
+ </ThemeContextProvider>
152
+ );
153
+
154
+ const input = screen.getByTestId('ucl-uikit-input');
155
+ expect(input).toHaveValue('112233');
156
+ expect(
157
+ screen.getByTestId('ucl-uikit-input-formatted-value')
158
+ ).toHaveTextContent('11-22-33');
159
+
160
+ await user.click(input);
161
+ expect(
162
+ screen.queryByTestId('ucl-uikit-input-formatted-value')
163
+ ).not.toBeInTheDocument();
164
+
165
+ await user.clear(input);
166
+ await user.type(input, '445566');
167
+ await user.tab();
168
+
169
+ expect(input).toHaveValue('445566');
170
+ expect(
171
+ screen.getByTestId('ucl-uikit-input-formatted-value')
172
+ ).toHaveTextContent('44-55-66');
173
+ });
174
+
175
+ test('formats a value with a function', () => {
176
+ render(
177
+ <ThemeContextProvider>
178
+ <Input
179
+ value='1234.5'
180
+ format={(value) => `£${value}`}
181
+ onChange={() => {}}
182
+ />
183
+ </ThemeContextProvider>
184
+ );
185
+
186
+ expect(
187
+ screen.getByTestId('ucl-uikit-input-formatted-value')
188
+ ).toHaveTextContent('£1234.5');
189
+ });
190
+
191
+ test('submits the raw value when formatted', () => {
192
+ render(
193
+ <ThemeContextProvider>
194
+ <form data-testid='form'>
195
+ <Input
196
+ name='sortCode'
197
+ defaultValue='112233'
198
+ format='##-##-##'
199
+ />
200
+ </form>
201
+ </ThemeContextProvider>
202
+ );
203
+
204
+ const form = screen.getByTestId('form') as HTMLFormElement;
205
+ expect(new FormData(form).get('sortCode')).toBe('112233');
206
+ });
207
+
208
+ test('resynchronises a formatted value after form reset', async () => {
209
+ const user = userEvent.setup();
210
+ render(
211
+ <ThemeContextProvider>
212
+ <form data-testid='form'>
213
+ <Input
214
+ defaultValue='112233'
215
+ format='##-##-##'
216
+ />
217
+ </form>
218
+ </ThemeContextProvider>
219
+ );
220
+
221
+ const input = screen.getByTestId('ucl-uikit-input');
222
+ const form = screen.getByTestId('form') as HTMLFormElement;
223
+ await user.click(input);
224
+ await user.clear(input);
225
+ await user.type(input, '445566');
226
+ await user.tab();
227
+
228
+ form.reset();
229
+
230
+ await waitFor(() => {
231
+ expect(input).toHaveValue('112233');
232
+ expect(
233
+ screen.getByTestId('ucl-uikit-input-formatted-value')
234
+ ).toHaveTextContent('11-22-33');
235
+ });
236
+ });
142
237
  });
@@ -8,7 +8,7 @@ const applyMaskTemplate = (value: string, template: string) => {
8
8
  for (const templateCharacter of Array.from(template)) {
9
9
  if (valueIndex >= valueCharacters.length) break;
10
10
 
11
- if (templateCharacter === '1') {
11
+ if (templateCharacter === '#') {
12
12
  maskedValue += valueCharacters[valueIndex];
13
13
  valueIndex += 1;
14
14
  } else if (templateCharacter === '*') {
@@ -0,0 +1,29 @@
1
+ export type DisplayValueFormat = string | ((value: string) => string);
2
+
3
+ const applyFormatTemplate = (value: string, template: string) => {
4
+ const valueCharacters = Array.from(value);
5
+ let valueIndex = 0;
6
+ let formattedValue = '';
7
+
8
+ for (const templateCharacter of Array.from(template)) {
9
+ if (valueIndex >= valueCharacters.length) break;
10
+
11
+ if (templateCharacter === '#') {
12
+ formattedValue += valueCharacters[valueIndex];
13
+ valueIndex += 1;
14
+ } else {
15
+ formattedValue += templateCharacter;
16
+ }
17
+ }
18
+
19
+ return formattedValue + valueCharacters.slice(valueIndex).join('');
20
+ };
21
+
22
+ const formatDisplayValue = (value: string, format?: DisplayValueFormat) => {
23
+ if (!format) return value;
24
+ return typeof format === 'function'
25
+ ? format(value)
26
+ : applyFormatTemplate(value, format);
27
+ };
28
+
29
+ export default formatDisplayValue;
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.49.0",
5
+ "version": "0.49.1",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
8
8
  "types": "dist/index.d.ts",