uikit-react-public 0.48.1 → 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 (26) 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 +2 -1
  6. package/dist/components/DisplayField/DisplayField.d.ts +11 -0
  7. package/dist/components/DisplayField/DisplayField.stories.d.ts +16 -0
  8. package/dist/components/DisplayField/__tests__/DisplayField.test.d.ts +1 -0
  9. package/dist/components/DisplayField/index.d.ts +2 -0
  10. package/dist/components/common/formatConfidentialValue.d.ts +3 -0
  11. package/dist/components/index.d.ts +4 -0
  12. package/dist/index.js +4843 -4746
  13. package/lib/components/ConfidentialDisplayField/ConfidentialDisplayField.stories.tsx +42 -0
  14. package/lib/components/ConfidentialDisplayField/ConfidentialDisplayField.tsx +103 -0
  15. package/lib/components/ConfidentialDisplayField/Documentation.mdx +43 -0
  16. package/lib/components/ConfidentialDisplayField/__tests__/ConfidentialDisplayField.test.tsx +125 -0
  17. package/lib/components/ConfidentialDisplayField/index.ts +2 -0
  18. package/lib/components/ConfidentialInput/ConfidentialInput.tsx +8 -30
  19. package/lib/components/DisplayField/DisplayField.stories.tsx +26 -0
  20. package/lib/components/DisplayField/DisplayField.tsx +56 -0
  21. package/lib/components/DisplayField/Documentation.mdx +35 -0
  22. package/lib/components/DisplayField/__tests__/DisplayField.test.tsx +61 -0
  23. package/lib/components/DisplayField/index.ts +2 -0
  24. package/lib/components/common/formatConfidentialValue.ts +36 -0
  25. package/lib/components/index.ts +6 -0
  26. 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';
@@ -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,7 +26,7 @@ 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;
28
31
  visibleWhenFocused?: boolean;
29
32
  }
@@ -37,30 +40,6 @@ const getStringValue = (
37
40
  return Array.isArray(value) ? value.join(',') : String(value);
38
41
  };
39
42
 
40
- const applyMaskTemplate = (value: string, template: string) => {
41
- const valueCharacters = Array.from(value);
42
- let valueIndex = 0;
43
- let maskedValue = '';
44
-
45
- for (const templateCharacter of Array.from(template)) {
46
- if (valueIndex >= valueCharacters.length) break;
47
-
48
- if (templateCharacter === '1') {
49
- maskedValue += valueCharacters[valueIndex];
50
- valueIndex += 1;
51
- } else if (templateCharacter === '*') {
52
- maskedValue += '*';
53
- valueIndex += 1;
54
- } else {
55
- maskedValue += templateCharacter;
56
- }
57
- }
58
-
59
- return (
60
- maskedValue + '*'.repeat(Math.max(0, valueCharacters.length - valueIndex))
61
- );
62
- };
63
-
64
43
  const ConfidentialInput = forwardRef<Ref, ConfidentialInputProps>(
65
44
  (
66
45
  {
@@ -142,11 +121,10 @@ const ConfidentialInput = forwardRef<Ref, ConfidentialInputProps>(
142
121
 
143
122
  const stringValue =
144
123
  value === undefined ? uncontrolledValue : getStringValue(value);
145
- const maskedValue =
146
- typeof maskFormat === 'function'
147
- ? maskFormat(stringValue)
148
- : applyMaskTemplate(stringValue, maskFormat);
149
- const displayedMaskedValue = maskedValue.split('*').join('•');
124
+ const displayedMaskedValue = formatConfidentialValue(
125
+ stringValue,
126
+ maskFormat
127
+ );
150
128
  const configuredMaskIsVisible =
151
129
  !valueIsPersistentlyShown && !isFocused && stringValue.length > 0;
152
130
 
@@ -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
+ };
@@ -0,0 +1,56 @@
1
+ import { forwardRef, memo, OutputHTMLAttributes, use } from 'react';
2
+ import { css, cx } from '@emotion/css';
3
+ import useTheme from '../../theme/useTheme';
4
+ import { FieldContext } from '../Field';
5
+ import marginsStyle, { MarginProps } from '../common/marginsStyle';
6
+
7
+ export const NAME = 'ucl-uikit-display-field';
8
+
9
+ export interface DisplayFieldBaseProps extends Omit<
10
+ OutputHTMLAttributes<HTMLOutputElement>,
11
+ 'children' | 'value'
12
+ > {
13
+ value: string | number;
14
+ testId?: string;
15
+ }
16
+
17
+ export type DisplayFieldProps = DisplayFieldBaseProps & MarginProps;
18
+
19
+ export type Ref = HTMLOutputElement;
20
+
21
+ const DisplayField = forwardRef<Ref, DisplayFieldProps>(
22
+ ({ value, testId = NAME, className, ...props }, ref) => {
23
+ const [theme] = useTheme();
24
+ const { id: contextId } = use(FieldContext);
25
+ const id = props.id ?? contextId;
26
+ const { md } = theme.typography.body;
27
+
28
+ const baseStyle = css`
29
+ display: block;
30
+ min-height: 24px;
31
+ color: ${theme.colour.text.default};
32
+ font-family: ${md.fontFamily};
33
+ font-feature-settings: ${md.fontSettings};
34
+ font-size: ${md.fontSize}px;
35
+ font-weight: ${md.fontWeight};
36
+ line-height: ${md.lineHeight}%;
37
+ overflow-wrap: anywhere;
38
+ `;
39
+
40
+ const style = cx(NAME, baseStyle, marginsStyle(props, theme), className);
41
+
42
+ return (
43
+ <output
44
+ {...props}
45
+ ref={ref}
46
+ id={id}
47
+ className={style}
48
+ data-testid={testId}
49
+ >
50
+ {value}
51
+ </output>
52
+ );
53
+ }
54
+ );
55
+
56
+ export default memo(DisplayField);
@@ -0,0 +1,35 @@
1
+ import * as DisplayFieldStories from './DisplayField.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={DisplayFieldStories} />
12
+ <Title />
13
+ <Subtitle>A read-only field for displaying a single value</Subtitle>
14
+
15
+ `DisplayField` renders a semantic HTML `<output>` element. Use it with `Label`
16
+ to display a record in the same field structure used by editable forms.
17
+
18
+ <Source
19
+ code={`<Field>
20
+ <Label>Account name</Label>
21
+ <DisplayField value='Personal savings' />
22
+ </Field>`}
23
+ />
24
+
25
+ ## Props
26
+
27
+ - `value`: **string | number** - The value to display.
28
+ - All standard HTML `<output>` props are supported.
29
+
30
+ <ArgTypes />
31
+
32
+ ## Examples
33
+
34
+ <Canvas of={DisplayFieldStories.Text} />
35
+ <Canvas of={DisplayFieldStories.Number} />
@@ -0,0 +1,61 @@
1
+ import { createRef } from 'react';
2
+ import { describe, expect, test } from 'vitest';
3
+ import { render, screen } from '@testing-library/react';
4
+ import { ThemeContextProvider } from '../../../theme/useTheme';
5
+ import DisplayField from '../DisplayField';
6
+ import Field from '../../Field';
7
+ import Label from '../../Label';
8
+
9
+ const renderDisplayField = (props: React.ComponentProps<typeof DisplayField>) =>
10
+ render(
11
+ <ThemeContextProvider>
12
+ <DisplayField {...props} />
13
+ </ThemeContextProvider>
14
+ );
15
+
16
+ describe('DisplayField', () => {
17
+ test('displays a text value', () => {
18
+ renderDisplayField({ value: 'Personal savings' });
19
+
20
+ expect(screen.getByTestId('ucl-uikit-display-field')).toHaveTextContent(
21
+ 'Personal savings'
22
+ );
23
+ });
24
+
25
+ test('displays a numeric value', () => {
26
+ renderDisplayField({ value: 12345678 });
27
+
28
+ expect(screen.getByTestId('ucl-uikit-display-field')).toHaveTextContent(
29
+ '12345678'
30
+ );
31
+ });
32
+
33
+ test('shares the Field id with its Label', () => {
34
+ render(
35
+ <ThemeContextProvider>
36
+ <Field>
37
+ <Label>Account name</Label>
38
+ <DisplayField value='Personal savings' />
39
+ </Field>
40
+ </ThemeContextProvider>
41
+ );
42
+
43
+ const label = screen.getByText('Account name');
44
+ const output = screen.getByTestId('ucl-uikit-display-field');
45
+ expect(label).toHaveAttribute('for', output.id);
46
+ });
47
+
48
+ test('forwards its ref to the output', () => {
49
+ const ref = createRef<HTMLOutputElement>();
50
+ render(
51
+ <ThemeContextProvider>
52
+ <DisplayField
53
+ ref={ref}
54
+ value='Personal savings'
55
+ />
56
+ </ThemeContextProvider>
57
+ );
58
+
59
+ expect(ref.current).toBe(screen.getByTestId('ucl-uikit-display-field'));
60
+ });
61
+ });
@@ -0,0 +1,2 @@
1
+ export { default } from './DisplayField';
2
+ export type { DisplayFieldProps } from './DisplayField';
@@ -0,0 +1,36 @@
1
+ export type MaskFormat = string | ((value: string) => string);
2
+
3
+ const applyMaskTemplate = (value: string, template: string) => {
4
+ const valueCharacters = Array.from(value);
5
+ let valueIndex = 0;
6
+ let maskedValue = '';
7
+
8
+ for (const templateCharacter of Array.from(template)) {
9
+ if (valueIndex >= valueCharacters.length) break;
10
+
11
+ if (templateCharacter === '1') {
12
+ maskedValue += valueCharacters[valueIndex];
13
+ valueIndex += 1;
14
+ } else if (templateCharacter === '*') {
15
+ maskedValue += '*';
16
+ valueIndex += 1;
17
+ } else {
18
+ maskedValue += templateCharacter;
19
+ }
20
+ }
21
+
22
+ return (
23
+ maskedValue + '*'.repeat(Math.max(0, valueCharacters.length - valueIndex))
24
+ );
25
+ };
26
+
27
+ const formatConfidentialValue = (value: string, maskFormat: MaskFormat) => {
28
+ const maskedValue =
29
+ typeof maskFormat === 'function'
30
+ ? maskFormat(value)
31
+ : applyMaskTemplate(value, maskFormat);
32
+
33
+ return maskedValue.split('*').join('•');
34
+ };
35
+
36
+ export default formatConfidentialValue;
@@ -7,6 +7,12 @@ export type { InputProps } from './Input';
7
7
  export { default as ConfidentialInput } from './ConfidentialInput';
8
8
  export type { ConfidentialInputProps } from './ConfidentialInput';
9
9
 
10
+ export { default as DisplayField } from './DisplayField';
11
+ export type { DisplayFieldProps } from './DisplayField';
12
+
13
+ export { default as ConfidentialDisplayField } from './ConfidentialDisplayField';
14
+ export type { ConfidentialDisplayFieldProps } from './ConfidentialDisplayField';
15
+
10
16
  export { default as Link } from './Link';
11
17
  export type { LinkProps } from './Link';
12
18
 
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.48.1",
5
+ "version": "0.49.0",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
8
8
  "types": "dist/index.d.ts",