Skip to content

Commit ab5dc22

Browse files
Add password input component
1 parent b52d05c commit ab5dc22

File tree

7 files changed

+518
-0
lines changed

7 files changed

+518
-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: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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+
} from 'react';
14+
15+
import { Button } from '#components/form-elements/button/index.js';
16+
import { FormGroup } from '#components/utils/index.js';
17+
import { type FormElementProps } from '#util/types/FormTypes.js';
18+
import { type InputWidth } from '#util/types/NHSUKTypes.js';
19+
20+
export interface PasswordInputElementProps extends ComponentPropsWithoutRef<'input'> {
21+
width?: InputWidth;
22+
showPasswordText?: string;
23+
showPasswordAriaLabelText?: string;
24+
buttonProps?: ComponentPropsWithRef<'button'>;
25+
}
26+
27+
export type PasswordInputProps = PasswordInputElementProps &
28+
Omit<
29+
FormElementProps<PasswordInputElementProps, 'input'>,
30+
'fieldsetProps' | 'legend' | 'legendProps'
31+
>;
32+
33+
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
34+
({ buttonProps, formGroupProps, ...props }, forwardedRef) => {
35+
const { showPasswordText, showPasswordAriaLabelText, ...rest } = props;
36+
const { className: buttonClassName, ...restButtonProps } = buttonProps ?? {};
37+
38+
const moduleRef = useRef<HTMLDivElement>(null);
39+
const importRef = useRef<Promise<PasswordInputModule | void>>(null);
40+
const [instanceError, setInstanceError] = useState<Error>();
41+
const [instance, setInstance] = useState<PasswordInputModule>();
42+
43+
useImperativeHandle(formGroupProps?.ref, () => moduleRef.current!, [moduleRef]);
44+
45+
useEffect(() => {
46+
if (!moduleRef.current || importRef.current || instance) {
47+
return;
48+
}
49+
50+
importRef.current = import('nhsuk-frontend')
51+
.then(({ PasswordInput }) => setInstance(new PasswordInput(moduleRef.current)))
52+
.catch(setInstanceError);
53+
}, [moduleRef, importRef, instance]);
54+
55+
if (instanceError) {
56+
throw instanceError;
57+
}
58+
59+
return (
60+
<FormGroup<PasswordInputProps, 'input'>
61+
{...rest}
62+
formGroupProps={{
63+
...formGroupProps,
64+
'className': classNames('nhsuk-password-input', formGroupProps?.className),
65+
'data-module': 'nhsuk-password-input',
66+
'afterInput': ({ id }) => (
67+
<Button
68+
className={classNames(
69+
'nhsuk-password-input__toggle nhsuk-js-password-input-toggle',
70+
buttonClassName,
71+
)}
72+
type="button"
73+
aria-controls={id}
74+
aria-label={showPasswordAriaLabelText ?? 'Show password'}
75+
secondary
76+
small
77+
hidden
78+
{...restButtonProps}
79+
>
80+
{showPasswordText ?? 'Show'}
81+
</Button>
82+
),
83+
'ref': moduleRef,
84+
}}
85+
>
86+
{({ width, className, error, autoComplete, ...rest }) => (
87+
<input
88+
className={classNames(
89+
'nhsuk-input',
90+
{ [`nhsuk-input--width-${width}`]: width },
91+
{ 'nhsuk-input--error': error },
92+
'nhsuk-password-input__input nhsuk-js-password-input-input',
93+
className,
94+
)}
95+
ref={forwardedRef}
96+
type="password"
97+
spellCheck="false"
98+
autoCapitalize="none"
99+
autoComplete={autoComplete ?? 'current-password'}
100+
{...rest}
101+
/>
102+
)}
103+
</FormGroup>
104+
);
105+
},
106+
);
107+
108+
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)