Skip to content

Commit c8cef9c

Browse files
Add password input component
1 parent 2524c84 commit c8cef9c

File tree

7 files changed

+418
-0
lines changed

7 files changed

+418
-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: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
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="Password" labelProps={{ size: 'l' }} id="password" />,
16+
{ moduleName: 'nhsuk-password-input' },
17+
);
18+
19+
expect(container).toMatchSnapshot('PasswordInput');
20+
});
21+
22+
it('matches snapshot (via server)', async () => {
23+
const { container, element } = await renderServer(
24+
<PasswordInput label="Password" labelProps={{ size: 'l' }} id="password" />,
25+
{ moduleName: 'nhsuk-password-input' },
26+
);
27+
28+
expect(container).toMatchSnapshot('server');
29+
30+
await renderClient(element, {
31+
moduleName: 'nhsuk-password-input',
32+
hydrate: true,
33+
container,
34+
});
35+
36+
expect(container).toMatchSnapshot('client');
37+
});
38+
39+
it('forwards refs', async () => {
40+
const groupRef = createRef<HTMLDivElement>();
41+
const fieldRef = createRef<HTMLInputElement>();
42+
const buttonRef = createRef<HTMLButtonElement>();
43+
44+
const { container } = await renderClient(
45+
<PasswordInput
46+
formGroupProps={{ ref: groupRef }}
47+
buttonProps={{ ref: buttonRef }}
48+
ref={fieldRef}
49+
/>,
50+
{ className: 'nhsuk-password-input' },
51+
);
52+
53+
const groupEl = container.querySelector('div');
54+
const inputEl = container.querySelector('input');
55+
const buttonEl = container.querySelector('button');
56+
57+
expect(groupRef.current).toBe(groupEl);
58+
expect(groupRef.current).toHaveClass('nhsuk-form-group');
59+
60+
expect(fieldRef.current).toBe(inputEl);
61+
expect(fieldRef.current).toHaveClass('nhsuk-input');
62+
63+
expect(buttonRef.current).toBe(buttonEl);
64+
expect(buttonRef.current).toHaveClass('nhsuk-password-input__toggle');
65+
});
66+
67+
it('should handle DOM events where ref exists', async () => {
68+
const ref = createRef<HTMLInputElement>();
69+
const mock = jest.fn();
70+
71+
const handleClick = () => {
72+
if (!ref.current) return;
73+
mock();
74+
};
75+
76+
const { modules } = await renderClient(<PasswordInput onClick={handleClick} ref={ref} />, {
77+
className: 'nhsuk-input',
78+
});
79+
80+
const [inputEl] = modules;
81+
inputEl.click();
82+
83+
expect(mock).toHaveBeenCalledTimes(1);
84+
});
85+
86+
it.each<InputWidth | undefined>([undefined, '5', '10'])(
87+
'sets the provided input width if specified with %s',
88+
async (width) => {
89+
const { modules } = await renderClient(<PasswordInput width={width} />, {
90+
className: 'nhsuk-input',
91+
});
92+
93+
const [inputEl] = modules;
94+
95+
if (width) {
96+
expect(inputEl).toHaveClass(`nhsuk-input--width-${width}`);
97+
} else {
98+
expect(inputEl.className.indexOf('nhsuk-input--width')).toBe(-1);
99+
}
100+
},
101+
);
102+
103+
it('sets the error class when error message is provided', async () => {
104+
const { modules } = await renderClient(
105+
<>
106+
<PasswordInput error={undefined} />
107+
<PasswordInput error="Enter password" />
108+
</>,
109+
{ className: 'nhsuk-input' },
110+
);
111+
112+
const [inputEl1, inputEl2] = modules;
113+
114+
expect(inputEl1).not.toHaveClass('nhsuk-input--error');
115+
expect(inputEl2).toHaveClass('nhsuk-input--error');
116+
});
117+
118+
it('sets the provided input width if specified', async () => {
119+
const { modules } = await renderClient(<PasswordInput width="5" />, {
120+
className: 'nhsuk-input',
121+
});
122+
123+
const [inputEl] = modules;
124+
125+
expect(inputEl).toHaveClass('nhsuk-input--width-5');
126+
});
127+
});
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2+
3+
exports[`PasswordInput matches snapshot (via server): client 1`] = `
4+
<div>
5+
<div
6+
class="nhsuk-form-group nhsuk-password-input"
7+
data-module="nhsuk-password-input"
8+
data-nhsuk-password-input-init=""
9+
>
10+
<label
11+
class="nhsuk-label nhsuk-label--l"
12+
for="password"
13+
id="password--label"
14+
>
15+
Password
16+
</label>
17+
<div
18+
class="nhsuk-input-wrapper"
19+
>
20+
<input
21+
autocapitalize="none"
22+
autocomplete="current-password"
23+
class="nhsuk-input nhsuk-password-input__input nhsuk-js-password-input-input"
24+
id="password"
25+
name="password"
26+
spellcheck="false"
27+
type="password"
28+
/>
29+
<div
30+
aria-live="polite"
31+
class="nhsuk-password-input__sr-status nhsuk-u-visually-hidden"
32+
/>
33+
<button
34+
aria-controls="password"
35+
aria-label="Show password"
36+
class="nhsuk-button nhsuk-button--secondary nhsuk-button--small nhsuk-password-input__toggle nhsuk-js-password-input-toggle"
37+
data-module="nhsuk-button"
38+
data-nhsuk-button-init=""
39+
type="button"
40+
>
41+
Show
42+
</button>
43+
</div>
44+
</div>
45+
</div>
46+
`;
47+
48+
exports[`PasswordInput matches snapshot (via server): server 1`] = `
49+
<div>
50+
<div
51+
class="nhsuk-form-group nhsuk-password-input"
52+
data-module="nhsuk-password-input"
53+
>
54+
<label
55+
class="nhsuk-label nhsuk-label--l"
56+
for="password"
57+
id="password--label"
58+
>
59+
Password
60+
</label>
61+
<div
62+
class="nhsuk-input-wrapper"
63+
>
64+
<input
65+
autocapitalize="none"
66+
autocomplete="current-password"
67+
class="nhsuk-input nhsuk-password-input__input nhsuk-js-password-input-input"
68+
id="password"
69+
name="password"
70+
spellcheck="false"
71+
type="password"
72+
/>
73+
<button
74+
aria-controls="password"
75+
aria-label="Show password"
76+
class="nhsuk-button nhsuk-button--secondary nhsuk-button--small nhsuk-password-input__toggle nhsuk-js-password-input-toggle"
77+
data-module="nhsuk-button"
78+
hidden=""
79+
type="button"
80+
>
81+
Show
82+
</button>
83+
</div>
84+
</div>
85+
</div>
86+
`;
87+
88+
exports[`PasswordInput matches snapshot: PasswordInput 1`] = `
89+
<div>
90+
<div
91+
class="nhsuk-form-group nhsuk-password-input"
92+
data-module="nhsuk-password-input"
93+
data-nhsuk-password-input-init=""
94+
>
95+
<label
96+
class="nhsuk-label nhsuk-label--l"
97+
for="password"
98+
id="password--label"
99+
>
100+
Password
101+
</label>
102+
<div
103+
class="nhsuk-input-wrapper"
104+
>
105+
<input
106+
autocapitalize="none"
107+
autocomplete="current-password"
108+
class="nhsuk-input nhsuk-password-input__input nhsuk-js-password-input-input"
109+
id="password"
110+
name="password"
111+
spellcheck="false"
112+
type="password"
113+
/>
114+
<div
115+
aria-live="polite"
116+
class="nhsuk-password-input__sr-status nhsuk-u-visually-hidden"
117+
/>
118+
<button
119+
aria-controls="password"
120+
aria-label="Show password"
121+
class="nhsuk-button nhsuk-button--secondary nhsuk-button--small nhsuk-password-input__toggle nhsuk-js-password-input-toggle"
122+
data-module="nhsuk-button"
123+
data-nhsuk-button-init=""
124+
type="button"
125+
>
126+
Show
127+
</button>
128+
</div>
129+
</div>
130+
</div>
131+
`;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './PasswordInput.js';

0 commit comments

Comments
 (0)