uikit-react-public 0.48.0 → 0.49.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.
Files changed (30) hide show
  1. package/dist/components/ConfidentialDisplayField/ConfidentialDisplayField.d.ts +10 -0
  2. package/dist/components/ConfidentialDisplayField/ConfidentialDisplayField.stories.d.ts +19 -0
  3. package/dist/components/ConfidentialDisplayField/__tests__/ConfidentialDisplayField.test.d.ts +1 -0
  4. package/dist/components/ConfidentialDisplayField/index.d.ts +2 -0
  5. package/dist/components/ConfidentialInput/ConfidentialInput.d.ts +3 -1
  6. package/dist/components/ConfidentialInput/ConfidentialInput.stories.d.ts +1 -0
  7. package/dist/components/DisplayField/DisplayField.d.ts +11 -0
  8. package/dist/components/DisplayField/DisplayField.stories.d.ts +16 -0
  9. package/dist/components/DisplayField/__tests__/DisplayField.test.d.ts +1 -0
  10. package/dist/components/DisplayField/index.d.ts +2 -0
  11. package/dist/components/common/formatConfidentialValue.d.ts +3 -0
  12. package/dist/components/index.d.ts +4 -0
  13. package/dist/index.js +5210 -5112
  14. package/lib/components/ConfidentialDisplayField/ConfidentialDisplayField.stories.tsx +42 -0
  15. package/lib/components/ConfidentialDisplayField/ConfidentialDisplayField.tsx +103 -0
  16. package/lib/components/ConfidentialDisplayField/Documentation.mdx +43 -0
  17. package/lib/components/ConfidentialDisplayField/__tests__/ConfidentialDisplayField.test.tsx +125 -0
  18. package/lib/components/ConfidentialDisplayField/index.ts +2 -0
  19. package/lib/components/ConfidentialInput/ConfidentialInput.stories.tsx +6 -0
  20. package/lib/components/ConfidentialInput/ConfidentialInput.tsx +24 -39
  21. package/lib/components/ConfidentialInput/Documentation.mdx +12 -4
  22. package/lib/components/ConfidentialInput/__tests__/ConfidentialInput.test.tsx +76 -9
  23. package/lib/components/DisplayField/DisplayField.stories.tsx +26 -0
  24. package/lib/components/DisplayField/DisplayField.tsx +56 -0
  25. package/lib/components/DisplayField/Documentation.mdx +35 -0
  26. package/lib/components/DisplayField/__tests__/DisplayField.test.tsx +61 -0
  27. package/lib/components/DisplayField/index.ts +2 -0
  28. package/lib/components/common/formatConfidentialValue.ts +36 -0
  29. package/lib/components/index.ts +6 -0
  30. package/package.json +1 -1
@@ -0,0 +1,42 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import ConfidentialDisplayField from './ConfidentialDisplayField';
3
+
4
+ const meta = {
5
+ title: 'Components/ConfidentialDisplayField',
6
+ component: ConfidentialDisplayField,
7
+ parameters: {
8
+ layout: 'centered',
9
+ },
10
+ tags: ['autodocs'],
11
+ args: {
12
+ value: '12345678',
13
+ maskFormat: '******11',
14
+ },
15
+ } satisfies Meta<typeof ConfidentialDisplayField>;
16
+
17
+ export default meta;
18
+
19
+ type Story = StoryObj<typeof meta>;
20
+
21
+ export const Default: Story = {};
22
+
23
+ export const Showable: Story = {
24
+ args: {
25
+ showable: true,
26
+ },
27
+ };
28
+
29
+ export const WithSeparators: Story = {
30
+ args: {
31
+ value: '123472',
32
+ maskFormat: '**-**-11',
33
+ showable: true,
34
+ },
35
+ };
36
+
37
+ export const FunctionMask: Story = {
38
+ args: {
39
+ maskFormat: (value) =>
40
+ `${'*'.repeat(Math.max(0, value.length - 2))}${value.slice(-2)}`,
41
+ },
42
+ };
@@ -0,0 +1,103 @@
1
+ import { forwardRef, memo, useEffect, useState } from 'react';
2
+ import { css, cx } from '@emotion/css';
3
+ import useTheme from '../../theme/useTheme';
4
+ import DisplayField, { DisplayFieldProps } from '../DisplayField';
5
+ import Icon from '../Icon';
6
+ import IconButton from '../IconButton';
7
+ import formatConfidentialValue, {
8
+ MaskFormat,
9
+ } from '../common/formatConfidentialValue';
10
+ import marginsStyle from '../common/marginsStyle';
11
+
12
+ export const NAME = 'ucl-uikit-confidential-display-field';
13
+
14
+ export interface ConfidentialDisplayFieldProps extends DisplayFieldProps {
15
+ maskFormat: MaskFormat;
16
+ showable?: boolean;
17
+ }
18
+
19
+ export type Ref = HTMLOutputElement;
20
+
21
+ const ConfidentialDisplayField = forwardRef<Ref, ConfidentialDisplayFieldProps>(
22
+ (
23
+ {
24
+ value,
25
+ maskFormat,
26
+ showable = false,
27
+ testId = NAME,
28
+ className,
29
+ m,
30
+ mv,
31
+ mh,
32
+ mt,
33
+ mb,
34
+ ml,
35
+ mr,
36
+ noMargins,
37
+ ...props
38
+ },
39
+ ref
40
+ ) => {
41
+ const [theme] = useTheme();
42
+ const [isShown, setIsShown] = useState(false);
43
+ const stringValue = String(value);
44
+ const valueIsShown = showable && isShown;
45
+ const displayedValue = valueIsShown
46
+ ? stringValue
47
+ : formatConfidentialValue(stringValue, maskFormat);
48
+
49
+ useEffect(() => {
50
+ if (!showable) setIsShown(false);
51
+ }, [showable]);
52
+
53
+ const wrapperStyle = cx(
54
+ NAME,
55
+ css`
56
+ display: inline-flex;
57
+ align-items: center;
58
+ gap: ${theme.padding.p8};
59
+ `,
60
+ marginsStyle({ m, mv, mh, mt, mb, ml, mr, noMargins }, theme),
61
+ className
62
+ );
63
+
64
+ return (
65
+ <span className={wrapperStyle}>
66
+ <DisplayField
67
+ {...props}
68
+ ref={ref}
69
+ value={displayedValue}
70
+ testId={testId}
71
+ noMargins
72
+ />
73
+ {showable && (
74
+ <IconButton
75
+ type='button'
76
+ aria-label={
77
+ valueIsShown
78
+ ? 'Hide confidential value'
79
+ : 'Show confidential value'
80
+ }
81
+ aria-pressed={valueIsShown}
82
+ testId={`${testId}-visibility-toggle`}
83
+ onClick={() => setIsShown((shown) => !shown)}
84
+ >
85
+ {valueIsShown ? (
86
+ <Icon.EyeOff
87
+ aria-hidden='true'
88
+ testId={`${testId}-hide-icon`}
89
+ />
90
+ ) : (
91
+ <Icon.Eye
92
+ aria-hidden='true'
93
+ testId={`${testId}-show-icon`}
94
+ />
95
+ )}
96
+ </IconButton>
97
+ )}
98
+ </span>
99
+ );
100
+ }
101
+ );
102
+
103
+ export default memo(ConfidentialDisplayField);
@@ -0,0 +1,43 @@
1
+ import * as ConfidentialDisplayFieldStories from './ConfidentialDisplayField.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={ConfidentialDisplayFieldStories} />
12
+ <Title />
13
+ <Subtitle>A DisplayField that masks confidential values</Subtitle>
14
+
15
+ `ConfidentialDisplayField` extends `DisplayField` with the same simple mask
16
+ format used by `ConfidentialInput`. `1` reveals a value character, `*` masks it
17
+ with a `•`, and other characters are displayed as separators.
18
+
19
+ <Source
20
+ code={`<ConfidentialDisplayField
21
+ value='12345678'
22
+ maskFormat='******11'
23
+ showable
24
+ />`}
25
+ />
26
+
27
+ ## Props
28
+
29
+ - `value`: **string | number** - The original value.
30
+ - `maskFormat`: **string | (value: string) => string** - Required mask template
31
+ or formatter.
32
+ - `showable`: **boolean** - Adds a button that toggles the original value.
33
+ Defaults to `false`.
34
+ - All `DisplayField` props are supported.
35
+
36
+ <ArgTypes />
37
+
38
+ ## Examples
39
+
40
+ <Canvas of={ConfidentialDisplayFieldStories.Default} />
41
+ <Canvas of={ConfidentialDisplayFieldStories.Showable} />
42
+ <Canvas of={ConfidentialDisplayFieldStories.WithSeparators} />
43
+ <Canvas of={ConfidentialDisplayFieldStories.FunctionMask} />
@@ -0,0 +1,125 @@
1
+ import { createRef } from 'react';
2
+ import { describe, expect, test } from 'vitest';
3
+ import { render, screen } from '@testing-library/react';
4
+ import userEvent from '@testing-library/user-event';
5
+ import { ThemeContextProvider } from '../../../theme/useTheme';
6
+ import ConfidentialDisplayField from '../ConfidentialDisplayField';
7
+
8
+ const renderDisplayField = (
9
+ props: React.ComponentProps<typeof ConfidentialDisplayField>
10
+ ) =>
11
+ render(
12
+ <ThemeContextProvider>
13
+ <ConfidentialDisplayField {...props} />
14
+ </ThemeContextProvider>
15
+ );
16
+
17
+ describe('ConfidentialDisplayField', () => {
18
+ test('applies a string mask', () => {
19
+ renderDisplayField({
20
+ value: '12345678',
21
+ maskFormat: '******11',
22
+ });
23
+
24
+ expect(
25
+ screen.getByTestId('ucl-uikit-confidential-display-field')
26
+ ).toHaveTextContent('••••••78');
27
+ });
28
+
29
+ test('applies a function mask', () => {
30
+ renderDisplayField({
31
+ value: 12345678,
32
+ maskFormat: (value) => `**** ending in ${value.slice(-2)}`,
33
+ });
34
+
35
+ expect(
36
+ screen.getByTestId('ucl-uikit-confidential-display-field')
37
+ ).toHaveTextContent('•••• ending in 78');
38
+ });
39
+
40
+ test('does not add a visibility control by default', () => {
41
+ renderDisplayField({
42
+ value: '12345678',
43
+ maskFormat: '******11',
44
+ });
45
+
46
+ expect(
47
+ screen.queryByRole('button', { name: 'Show confidential value' })
48
+ ).not.toBeInTheDocument();
49
+ });
50
+
51
+ test('toggles the original value', async () => {
52
+ const user = userEvent.setup();
53
+ renderDisplayField({
54
+ value: '12345678',
55
+ maskFormat: '******11',
56
+ showable: true,
57
+ });
58
+
59
+ const output = screen.getByTestId('ucl-uikit-confidential-display-field');
60
+ await user.click(
61
+ screen.getByRole('button', { name: 'Show confidential value' })
62
+ );
63
+
64
+ expect(output).toHaveTextContent('12345678');
65
+ expect(
66
+ screen.getByRole('button', { name: 'Hide confidential value' })
67
+ ).toHaveAttribute('aria-pressed', 'true');
68
+
69
+ await user.click(
70
+ screen.getByRole('button', { name: 'Hide confidential value' })
71
+ );
72
+ expect(output).toHaveTextContent('••••••78');
73
+ });
74
+
75
+ test('forwards its ref to the output', () => {
76
+ const ref = createRef<HTMLOutputElement>();
77
+ render(
78
+ <ThemeContextProvider>
79
+ <ConfidentialDisplayField
80
+ ref={ref}
81
+ value='12345678'
82
+ maskFormat='******11'
83
+ />
84
+ </ThemeContextProvider>
85
+ );
86
+
87
+ expect(ref.current).toBe(
88
+ screen.getByTestId('ucl-uikit-confidential-display-field')
89
+ );
90
+ });
91
+
92
+ test('hides the value when showable changes to false', async () => {
93
+ const user = userEvent.setup();
94
+ const { rerender } = render(
95
+ <ThemeContextProvider>
96
+ <ConfidentialDisplayField
97
+ value='12345678'
98
+ maskFormat='******11'
99
+ showable
100
+ />
101
+ </ThemeContextProvider>
102
+ );
103
+
104
+ await user.click(
105
+ screen.getByRole('button', { name: 'Show confidential value' })
106
+ );
107
+ expect(
108
+ screen.getByTestId('ucl-uikit-confidential-display-field')
109
+ ).toHaveTextContent('12345678');
110
+
111
+ rerender(
112
+ <ThemeContextProvider>
113
+ <ConfidentialDisplayField
114
+ value='12345678'
115
+ maskFormat='******11'
116
+ showable={false}
117
+ />
118
+ </ThemeContextProvider>
119
+ );
120
+
121
+ expect(
122
+ screen.getByTestId('ucl-uikit-confidential-display-field')
123
+ ).toHaveTextContent('••••••78');
124
+ });
125
+ });
@@ -0,0 +1,2 @@
1
+ export { default } from './ConfidentialDisplayField';
2
+ export type { ConfidentialDisplayFieldProps } from './ConfidentialDisplayField';
@@ -43,3 +43,9 @@ export const FunctionMask: Story = {
43
43
  showable: true,
44
44
  },
45
45
  };
46
+
47
+ export const MaskedWhenFocused: Story = {
48
+ args: {
49
+ visibleWhenFocused: false,
50
+ },
51
+ };
@@ -16,6 +16,9 @@ import IconButton from '../IconButton';
16
16
  import Input, { InputProps } from '../Input';
17
17
  import { FieldContext } from '../Field';
18
18
  import marginsStyle from '../common/marginsStyle';
19
+ import formatConfidentialValue, {
20
+ MaskFormat,
21
+ } from '../common/formatConfidentialValue';
19
22
 
20
23
  export const NAME = 'ucl-uikit-confidential-input';
21
24
 
@@ -23,8 +26,9 @@ export interface ConfidentialInputProps extends Omit<
23
26
  InputProps,
24
27
  'iconButton' | 'type'
25
28
  > {
26
- maskFormat: string | ((value: string) => string);
29
+ maskFormat: MaskFormat;
27
30
  showable?: boolean;
31
+ visibleWhenFocused?: boolean;
28
32
  }
29
33
 
30
34
  export type Ref = HTMLInputElement;
@@ -36,35 +40,12 @@ const getStringValue = (
36
40
  return Array.isArray(value) ? value.join(',') : String(value);
37
41
  };
38
42
 
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
43
  const ConfidentialInput = forwardRef<Ref, ConfidentialInputProps>(
64
44
  (
65
45
  {
66
46
  maskFormat,
67
47
  showable = false,
48
+ visibleWhenFocused = true,
68
49
  value,
69
50
  defaultValue,
70
51
  disabled,
@@ -97,7 +78,9 @@ const ConfidentialInput = forwardRef<Ref, ConfidentialInputProps>(
97
78
  getStringValue(defaultValue)
98
79
  );
99
80
  const resolvedDisabled = disabled ?? contextDisabled;
100
- const valueIsShown = showable && isShown;
81
+ const valueIsPersistentlyShown = showable && isShown;
82
+ const valueIsVisible =
83
+ (visibleWhenFocused && isFocused) || valueIsPersistentlyShown;
101
84
 
102
85
  const setInputRef = useCallback(
103
86
  (element: HTMLInputElement | null) => {
@@ -138,12 +121,12 @@ const ConfidentialInput = forwardRef<Ref, ConfidentialInputProps>(
138
121
 
139
122
  const stringValue =
140
123
  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;
124
+ const displayedMaskedValue = formatConfidentialValue(
125
+ stringValue,
126
+ maskFormat
127
+ );
128
+ const configuredMaskIsVisible =
129
+ !valueIsPersistentlyShown && !isFocused && stringValue.length > 0;
147
130
 
148
131
  const wrapperStyle = cx(
149
132
  NAME,
@@ -215,13 +198,15 @@ const ConfidentialInput = forwardRef<Ref, ConfidentialInputProps>(
215
198
  type='button'
216
199
  disabled={resolvedDisabled}
217
200
  aria-label={
218
- valueIsShown ? 'Hide confidential value' : 'Show confidential value'
201
+ valueIsPersistentlyShown
202
+ ? 'Hide confidential value'
203
+ : 'Show confidential value'
219
204
  }
220
- aria-pressed={valueIsShown}
205
+ aria-pressed={valueIsPersistentlyShown}
221
206
  testId={`${testId}-visibility-toggle`}
222
207
  onClick={() => setIsShown((shown) => !shown)}
223
208
  >
224
- {valueIsShown ? (
209
+ {valueIsPersistentlyShown ? (
225
210
  <Icon.EyeOff
226
211
  aria-hidden='true'
227
212
  testId={`${testId}-hide-icon`}
@@ -247,22 +232,22 @@ const ConfidentialInput = forwardRef<Ref, ConfidentialInputProps>(
247
232
  iconPosition={iconPosition}
248
233
  iconButton={visibilityButton}
249
234
  inputClassName={cx(
250
- visuallyHidden && hiddenInputStyle,
235
+ configuredMaskIsVisible && hiddenInputStyle,
251
236
  inputClassName
252
237
  )}
253
238
  testId={testId}
254
- type={valueIsShown ? 'text' : 'password'}
239
+ type={valueIsVisible ? 'text' : 'password'}
255
240
  onChange={handleChange}
256
241
  onFocus={handleFocus}
257
242
  onBlur={handleBlur}
258
243
  />
259
- {visuallyHidden && (
244
+ {configuredMaskIsVisible && (
260
245
  <span
261
246
  className={maskStyle}
262
247
  data-testid={`${testId}-mask`}
263
248
  aria-hidden='true'
264
249
  >
265
- {maskedValue}
250
+ {displayedMaskedValue}
266
251
  </span>
267
252
  )}
268
253
  </span>
@@ -20,10 +20,14 @@ change events, and form submission while displaying a masked value.
20
20
  ## Usage
21
21
 
22
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.
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.
27
31
 
28
32
  <Source
29
33
  code={`<ConfidentialInput
@@ -51,6 +55,9 @@ A function can be supplied when the template syntax is not sufficient:
51
55
  or formatter.
52
56
  - `showable`: **boolean** - Adds a button that toggles the original value.
53
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`.
54
61
  - All `Input` props except `type` and `iconButton` are supported.
55
62
 
56
63
  <ArgTypes />
@@ -60,3 +67,4 @@ A function can be supplied when the template syntax is not sufficient:
60
67
  <Canvas of={ConfidentialInputStories.Showable} />
61
68
  <Canvas of={ConfidentialInputStories.WithSeparators} />
62
69
  <Canvas of={ConfidentialInputStories.FunctionMask} />
70
+ <Canvas of={ConfidentialInputStories.MaskedWhenFocused} />
@@ -27,7 +27,7 @@ describe('ConfidentialInput', () => {
27
27
  );
28
28
  expect(
29
29
  screen.getByTestId('ucl-uikit-confidential-input-mask')
30
- ).toHaveTextContent('1234****');
30
+ ).toHaveTextContent('1234••••');
31
31
  });
32
32
 
33
33
  test('supports separators and masks characters beyond the template', () => {
@@ -39,19 +39,19 @@ describe('ConfidentialInput', () => {
39
39
 
40
40
  expect(
41
41
  screen.getByTestId('ucl-uikit-confidential-input-mask')
42
- ).toHaveTextContent('**-**-72*');
42
+ ).toHaveTextContent('••-••-72');
43
43
  });
44
44
 
45
45
  test('applies a function mask', () => {
46
46
  renderInput({
47
- maskFormat: (value) => `ending in ${value.slice(-2)}`,
47
+ maskFormat: (value) => `**** ending in ${value.slice(-2)}`,
48
48
  value: '12345678',
49
49
  onChange: () => {},
50
50
  });
51
51
 
52
52
  expect(
53
53
  screen.getByTestId('ucl-uikit-confidential-input-mask')
54
- ).toHaveTextContent('ending in 78');
54
+ ).toHaveTextContent('•••• ending in 78');
55
55
  });
56
56
 
57
57
  test('does not add a visibility control by default', () => {
@@ -73,7 +73,9 @@ describe('ConfidentialInput', () => {
73
73
  showable: true,
74
74
  });
75
75
 
76
- const input = screen.getByTestId('ucl-uikit-confidential-input');
76
+ const input = screen.getByTestId(
77
+ 'ucl-uikit-confidential-input'
78
+ ) as HTMLInputElement;
77
79
  const showButton = screen.getByRole('button', {
78
80
  name: 'Show confidential value',
79
81
  });
@@ -109,6 +111,7 @@ describe('ConfidentialInput', () => {
109
111
  await user.type(input, '1234');
110
112
 
111
113
  expect(input).toHaveValue('1234');
114
+ expect(input).toHaveAttribute('type', 'text');
112
115
  expect(
113
116
  screen.queryByTestId('ucl-uikit-confidential-input-mask')
114
117
  ).not.toBeInTheDocument();
@@ -117,7 +120,7 @@ describe('ConfidentialInput', () => {
117
120
 
118
121
  expect(
119
122
  screen.getByTestId('ucl-uikit-confidential-input-mask')
120
- ).toHaveTextContent('****');
123
+ ).toHaveTextContent('••••');
121
124
  expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({}));
122
125
  expect(onChange.mock.lastCall?.[0].target.value).toBe('1234');
123
126
  });
@@ -174,11 +177,41 @@ describe('ConfidentialInput', () => {
174
177
  });
175
178
  });
176
179
 
177
- test('uses native password rendering while editing formatted values', async () => {
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 () => {
178
210
  const user = userEvent.setup();
179
211
  renderInput({
180
212
  maskFormat: '**-**-11',
181
213
  defaultValue: '123472',
214
+ visibleWhenFocused: false,
182
215
  });
183
216
 
184
217
  const input = screen.getByTestId(
@@ -186,7 +219,7 @@ describe('ConfidentialInput', () => {
186
219
  ) as HTMLInputElement;
187
220
  expect(
188
221
  screen.getByTestId('ucl-uikit-confidential-input-mask')
189
- ).toHaveTextContent('**-**-72');
222
+ ).toHaveTextContent('••-••-72');
190
223
 
191
224
  await user.click(input);
192
225
 
@@ -201,7 +234,41 @@ describe('ConfidentialInput', () => {
201
234
 
202
235
  expect(
203
236
  screen.getByTestId('ucl-uikit-confidential-input-mask')
204
- ).toHaveTextContent('**-**-72');
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');
205
272
  });
206
273
 
207
274
  test('supports a right icon alongside the visibility control', () => {
@@ -0,0 +1,26 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import DisplayField from './DisplayField';
3
+
4
+ const meta = {
5
+ title: 'Components/DisplayField',
6
+ component: DisplayField,
7
+ parameters: {
8
+ layout: 'centered',
9
+ },
10
+ tags: ['autodocs'],
11
+ args: {
12
+ value: 'Personal savings',
13
+ },
14
+ } satisfies Meta<typeof DisplayField>;
15
+
16
+ export default meta;
17
+
18
+ type Story = StoryObj<typeof meta>;
19
+
20
+ export const Text: Story = {};
21
+
22
+ export const Number: Story = {
23
+ args: {
24
+ value: 12345678,
25
+ },
26
+ };