Skip to content

Commit 0a52e31

Browse files
Add password input component
1 parent dcf374f commit 0a52e31

File tree

7 files changed

+527
-0
lines changed

7 files changed

+527
-0
lines changed

src/__tests__/index.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ describe('Index', () => {
101101
'PaginationLinkText',
102102
'Panel',
103103
'PanelTitle',
104+
'PasswordInput',
104105
'Radios',
105106
'RadiosContext',
106107
'RadiosDivider',

src/components/form-elements/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export * from './form/index.js';
99
export * from './hint-text/index.js';
1010
export * from './label/index.js';
1111
export * from './legend/index.js';
12+
export * from './password-input/index.js';
1213
export * from './radios/index.js';
1314
export * from './select/index.js';
1415
export * from './text-input/index.js';
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
'use client';
2+
3+
import classNames from 'classnames';
4+
import { type PasswordInput as PasswordInputModule } from 'nhsuk-frontend';
5+
import {
6+
forwardRef,
7+
useEffect,
8+
useImperativeHandle,
9+
useRef,
10+
useState,
11+
type ComponentPropsWithoutRef,
12+
type ComponentPropsWithRef,
13+
type FC,
14+
} from 'react';
15+
16+
import { Button } from '#components/form-elements/button/index.js';
17+
import { FormGroup } from '#components/utils/index.js';
18+
import { type FormElementProps } from '#util/types/FormTypes.js';
19+
import { type InputWidth } from '#util/types/NHSUKTypes.js';
20+
21+
export interface PasswordInputElementProps extends ComponentPropsWithoutRef<'input'> {
22+
width?: InputWidth;
23+
showPasswordText?: string;
24+
showPasswordAriaLabelText?: string;
25+
buttonProps?: ComponentPropsWithRef<'button'>;
26+
}
27+
28+
export type PasswordInputProps = PasswordInputElementProps &
29+
Omit<
30+
FormElementProps<PasswordInputElementProps, 'input'>,
31+
'fieldsetProps' | 'legend' | 'legendProps'
32+
>;
33+
34+
const PasswordInputButton: FC<ComponentPropsWithRef<'button'>> = ({
35+
children,
36+
className,
37+
...rest
38+
}) => (
39+
<Button
40+
className={classNames('nhsuk-password-input__toggle nhsuk-js-password-input-toggle', className)}
41+
type="button"
42+
secondary
43+
small
44+
hidden
45+
{...rest}
46+
>
47+
{children}
48+
</Button>
49+
);
50+
51+
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
52+
({ buttonProps, formGroupProps, ...props }, forwardedRef) => {
53+
const { showPasswordText, showPasswordAriaLabelText, ...rest } = props;
54+
55+
const moduleRef = useRef<HTMLDivElement>(null);
56+
const importRef = useRef<Promise<PasswordInputModule | void>>(null);
57+
const [instanceError, setInstanceError] = useState<Error>();
58+
const [instance, setInstance] = useState<PasswordInputModule>();
59+
60+
useImperativeHandle(formGroupProps?.ref, () => moduleRef.current!, [moduleRef]);
61+
62+
useEffect(() => {
63+
if (!moduleRef.current || importRef.current || instance) {
64+
return;
65+
}
66+
67+
importRef.current = import('nhsuk-frontend')
68+
.then(({ PasswordInput }) => setInstance(new PasswordInput(moduleRef.current)))
69+
.catch(setInstanceError);
70+
}, [moduleRef, importRef, instance]);
71+
72+
if (instanceError) {
73+
throw instanceError;
74+
}
75+
76+
return (
77+
<FormGroup<PasswordInputProps, 'input'>
78+
{...rest}
79+
formGroupProps={{
80+
...formGroupProps,
81+
'className': classNames('nhsuk-password-input', formGroupProps?.className),
82+
'data-module': 'nhsuk-password-input',
83+
'afterInput': ({ id }) => (
84+
<PasswordInputButton
85+
aria-controls={id}
86+
aria-label={showPasswordAriaLabelText ?? 'Show password'}
87+
{...buttonProps}
88+
>
89+
{showPasswordText ?? 'Show'}
90+
</PasswordInputButton>
91+
),
92+
'ref': moduleRef,
93+
}}
94+
>
95+
{({ width, className, error, autoComplete, ...rest }) => (
96+
<input
97+
className={classNames(
98+
'nhsuk-input',
99+
{ [`nhsuk-input--width-${width}`]: width },
100+
{ 'nhsuk-input--error': error },
101+
'nhsuk-password-input__input nhsuk-js-password-input-input',
102+
className,
103+
)}
104+
ref={forwardedRef}
105+
type="password"
106+
spellCheck="false"
107+
autoCapitalize="none"
108+
autoComplete={autoComplete ?? 'current-password'}
109+
{...rest}
110+
/>
111+
)}
112+
</FormGroup>
113+
);
114+
},
115+
);
116+
117+
PasswordInput.displayName = 'PasswordInput';
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { createRef } from 'react';
2+
3+
import { PasswordInput } from '..';
4+
5+
import { renderClient, renderServer } from '#util/components';
6+
import { type InputWidth } from '#util/types';
7+
8+
describe('PasswordInput', () => {
9+
afterEach(() => {
10+
jest.restoreAllMocks();
11+
});
12+
13+
it('matches snapshot', async () => {
14+
const { container } = await renderClient(
15+
<PasswordInput label="What is your NHS number?" labelProps={{ size: 'l' }} id="nhs-number" />,
16+
{ className: 'nhsuk-input' },
17+
);
18+
19+
expect(container).toMatchSnapshot('PasswordInput');
20+
});
21+
22+
it('matches snapshot with HTML in props', async () => {
23+
const { container } = await renderClient(
24+
<PasswordInput
25+
label={
26+
<>
27+
<span className="nhsuk-caption-l">Example</span> Label text
28+
</>
29+
}
30+
labelProps={{
31+
isPageHeading: true,
32+
size: 'l',
33+
}}
34+
hint={
35+
<>
36+
Hint text <em>with HTML</em>
37+
</>
38+
}
39+
error={
40+
<>
41+
Error text <em>with HTML</em>
42+
</>
43+
}
44+
id="nhs-number"
45+
/>,
46+
{ className: 'nhsuk-input' },
47+
);
48+
49+
expect(container).toMatchSnapshot();
50+
});
51+
52+
it('matches snapshot (via server)', async () => {
53+
const { container, element } = await renderServer(
54+
<PasswordInput label="What is your NHS number?" labelProps={{ size: 'l' }} id="nhs-number" />,
55+
{ className: 'nhsuk-input' },
56+
);
57+
58+
expect(container).toMatchSnapshot('server');
59+
60+
await renderClient(element, {
61+
className: 'nhsuk-input',
62+
hydrate: true,
63+
container,
64+
});
65+
66+
expect(container).toMatchSnapshot('client');
67+
});
68+
69+
it('forwards refs', async () => {
70+
const groupRef = createRef<HTMLDivElement>();
71+
const fieldRef = createRef<HTMLInputElement>();
72+
const buttonRef = createRef<HTMLButtonElement>();
73+
74+
const { container } = await renderClient(
75+
<PasswordInput
76+
formGroupProps={{ ref: groupRef }}
77+
buttonProps={{ ref: buttonRef }}
78+
ref={fieldRef}
79+
/>,
80+
{ className: 'nhsuk-password-input' },
81+
);
82+
83+
const groupEl = container.querySelector('div');
84+
const inputEl = container.querySelector('input');
85+
const buttonEl = container.querySelector('button');
86+
87+
expect(groupRef.current).toBe(groupEl);
88+
expect(groupRef.current).toHaveClass('nhsuk-form-group');
89+
90+
expect(fieldRef.current).toBe(inputEl);
91+
expect(fieldRef.current).toHaveClass('nhsuk-input');
92+
93+
expect(buttonRef.current).toBe(buttonEl);
94+
expect(buttonRef.current).toHaveClass('nhsuk-password-input__toggle');
95+
});
96+
97+
it('should handle DOM events where ref exists', async () => {
98+
const ref = createRef<HTMLInputElement>();
99+
const mock = jest.fn();
100+
101+
const handleClick = () => {
102+
if (!ref.current) return;
103+
mock();
104+
};
105+
106+
const { modules } = await renderClient(<PasswordInput onClick={handleClick} ref={ref} />, {
107+
className: 'nhsuk-input',
108+
});
109+
110+
const [inputEl] = modules;
111+
inputEl.click();
112+
113+
expect(mock).toHaveBeenCalledTimes(1);
114+
});
115+
116+
it.each<InputWidth | undefined>([undefined, '5', '10'])(
117+
'Sets the provided input width if specified with %s',
118+
async (width) => {
119+
const { modules } = await renderClient(<PasswordInput width={width} />, {
120+
className: 'nhsuk-input',
121+
});
122+
123+
const [inputEl] = modules;
124+
125+
if (width) {
126+
expect(inputEl).toHaveClass(`nhsuk-input--width-${width}`);
127+
} else {
128+
expect(inputEl.className.indexOf('nhsuk-input--width')).toBe(-1);
129+
}
130+
},
131+
);
132+
133+
it('sets the error class when error message is provided', async () => {
134+
const { modules } = await renderClient(
135+
<>
136+
<PasswordInput error={undefined} />
137+
<PasswordInput error="Enter password" />
138+
</>,
139+
{ className: 'nhsuk-input' },
140+
);
141+
142+
const [inputEl1, inputEl2] = modules;
143+
144+
expect(inputEl1).not.toHaveClass('nhsuk-input--error');
145+
expect(inputEl2).toHaveClass('nhsuk-input--error');
146+
});
147+
148+
it('sets the provided input width if specified', async () => {
149+
const { modules } = await renderClient(<PasswordInput width="5" />, {
150+
className: 'nhsuk-input',
151+
});
152+
153+
const [inputEl] = modules;
154+
155+
expect(inputEl).toHaveClass('nhsuk-input--width-5');
156+
});
157+
});

0 commit comments

Comments
 (0)