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.
- package/dist/components/ConfidentialDisplayField/ConfidentialDisplayField.d.ts +10 -0
- package/dist/components/ConfidentialDisplayField/ConfidentialDisplayField.stories.d.ts +19 -0
- package/dist/components/ConfidentialDisplayField/__tests__/ConfidentialDisplayField.test.d.ts +1 -0
- package/dist/components/ConfidentialDisplayField/index.d.ts +2 -0
- package/dist/components/ConfidentialInput/ConfidentialInput.d.ts +3 -1
- package/dist/components/ConfidentialInput/ConfidentialInput.stories.d.ts +1 -0
- package/dist/components/DisplayField/DisplayField.d.ts +11 -0
- package/dist/components/DisplayField/DisplayField.stories.d.ts +16 -0
- package/dist/components/DisplayField/__tests__/DisplayField.test.d.ts +1 -0
- package/dist/components/DisplayField/index.d.ts +2 -0
- package/dist/components/common/formatConfidentialValue.d.ts +3 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/index.js +5210 -5112
- package/lib/components/ConfidentialDisplayField/ConfidentialDisplayField.stories.tsx +42 -0
- package/lib/components/ConfidentialDisplayField/ConfidentialDisplayField.tsx +103 -0
- package/lib/components/ConfidentialDisplayField/Documentation.mdx +43 -0
- package/lib/components/ConfidentialDisplayField/__tests__/ConfidentialDisplayField.test.tsx +125 -0
- package/lib/components/ConfidentialDisplayField/index.ts +2 -0
- package/lib/components/ConfidentialInput/ConfidentialInput.stories.tsx +6 -0
- package/lib/components/ConfidentialInput/ConfidentialInput.tsx +24 -39
- package/lib/components/ConfidentialInput/Documentation.mdx +12 -4
- package/lib/components/ConfidentialInput/__tests__/ConfidentialInput.test.tsx +76 -9
- package/lib/components/DisplayField/DisplayField.stories.tsx +26 -0
- package/lib/components/DisplayField/DisplayField.tsx +56 -0
- package/lib/components/DisplayField/Documentation.mdx +35 -0
- package/lib/components/DisplayField/__tests__/DisplayField.test.tsx +61 -0
- package/lib/components/DisplayField/index.ts +2 -0
- package/lib/components/common/formatConfidentialValue.ts +36 -0
- package/lib/components/index.ts +6 -0
- 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
|
+
});
|
|
@@ -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:
|
|
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
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
!
|
|
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
|
-
|
|
201
|
+
valueIsPersistentlyShown
|
|
202
|
+
? 'Hide confidential value'
|
|
203
|
+
: 'Show confidential value'
|
|
219
204
|
}
|
|
220
|
-
aria-pressed={
|
|
205
|
+
aria-pressed={valueIsPersistentlyShown}
|
|
221
206
|
testId={`${testId}-visibility-toggle`}
|
|
222
207
|
onClick={() => setIsShown((shown) => !shown)}
|
|
223
208
|
>
|
|
224
|
-
{
|
|
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
|
-
|
|
235
|
+
configuredMaskIsVisible && hiddenInputStyle,
|
|
251
236
|
inputClassName
|
|
252
237
|
)}
|
|
253
238
|
testId={testId}
|
|
254
|
-
type={
|
|
239
|
+
type={valueIsVisible ? 'text' : 'password'}
|
|
255
240
|
onChange={handleChange}
|
|
256
241
|
onFocus={handleFocus}
|
|
257
242
|
onBlur={handleBlur}
|
|
258
243
|
/>
|
|
259
|
-
{
|
|
244
|
+
{configuredMaskIsVisible && (
|
|
260
245
|
<span
|
|
261
246
|
className={maskStyle}
|
|
262
247
|
data-testid={`${testId}-mask`}
|
|
263
248
|
aria-hidden='true'
|
|
264
249
|
>
|
|
265
|
-
{
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
the
|
|
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('
|
|
42
|
+
).toHaveTextContent('••-••-72•');
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
test('applies a function mask', () => {
|
|
46
46
|
renderInput({
|
|
47
|
-
maskFormat: (value) =>
|
|
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(
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
+
};
|