Skip to content

Commit f6d84f2

Browse files
committed
feat(form): added a PasswordWithMessage component to be used with useTextField Hook
1 parent e358799 commit f6d84f2

File tree

5 files changed

+347
-20
lines changed

5 files changed

+347
-20
lines changed

packages/form/src/text-field/Password.tsx

+24-20
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,7 @@ export interface ConfigurableVisibilityIcon {
3030

3131
export type GetVisibilityIcon = (type: "text" | "password") => ReactNode;
3232

33-
export interface PasswordProps
34-
extends Omit<TextFieldProps, "type" | "rightChildren"> {
33+
export interface PasswordProps extends Omit<TextFieldProps, "type"> {
3534
/**
3635
* The icon to use to toggle the visibility of the password by changing the
3736
* input type to text temporarily. This can either be a renderable React node
@@ -108,6 +107,8 @@ export const Password = forwardRef<HTMLInputElement, PasswordProps>(
108107
onVisibilityClick,
109108
getVisibilityIcon,
110109
disableVisibility = false,
110+
rightChildren: propRightChildren,
111+
isRightAddon = disableVisibility,
111112
...props
112113
},
113114
ref
@@ -133,6 +134,25 @@ export const Password = forwardRef<HTMLInputElement, PasswordProps>(
133134
: propVisibilityIcon.invisible;
134135
}
135136

137+
let rightChildren: ReactNode = propRightChildren;
138+
if (!disableVisibility) {
139+
rightChildren = (
140+
<Button
141+
id={`${id}-password-toggle`}
142+
aria-label={visibilityLabel}
143+
aria-pressed={visible}
144+
buttonType="icon"
145+
onClick={toggle}
146+
style={visibilityStyle}
147+
className={cn(block("toggle"), visibilityClassName)}
148+
>
149+
{typeof getVisibilityIcon === "function"
150+
? getVisibilityIcon(type)
151+
: visibilityIcon}
152+
</Button>
153+
);
154+
}
155+
136156
return (
137157
<TextField
138158
{...props}
@@ -143,24 +163,8 @@ export const Password = forwardRef<HTMLInputElement, PasswordProps>(
143163
)}
144164
ref={ref}
145165
type={type}
146-
isRightAddon={false}
147-
rightChildren={
148-
!disableVisibility && (
149-
<Button
150-
id={`${id}-password-toggle`}
151-
aria-label={visibilityLabel}
152-
aria-pressed={visible}
153-
buttonType="icon"
154-
onClick={toggle}
155-
style={visibilityStyle}
156-
className={cn(block("toggle"), visibilityClassName)}
157-
>
158-
{typeof getVisibilityIcon === "function"
159-
? getVisibilityIcon(type)
160-
: visibilityIcon}
161-
</Button>
162-
)
163-
}
166+
isRightAddon={isRightAddon}
167+
rightChildren={rightChildren}
164168
/>
165169
);
166170
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import React, { forwardRef, ReactElement } from "react";
2+
import {
3+
FieldMessageContainerExtension,
4+
FieldMessageContainer,
5+
} from "./FieldMessageContainer";
6+
7+
import { Password, PasswordProps } from "./Password";
8+
9+
export type PasswordWithMessageProps = FieldMessageContainerExtension<
10+
PasswordProps
11+
>;
12+
13+
/**
14+
* This component is a simple wrapper for the `Password` and `FormMessage`
15+
* components that should be used along with the `useTextField` hook to
16+
* conditionally show help and error messages with a `Password`.
17+
*
18+
* Simple example:
19+
*
20+
* ```ts
21+
* const [value, fieldProps] = useTextField({
22+
* id: "field-id",
23+
* required: true,
24+
* minLength: 10,
25+
* });
26+
*
27+
* return (
28+
* <PasswordWithMessage
29+
* label="Label"
30+
* placeholder="Placeholder"
31+
* {...fieldProps}
32+
* />
33+
* );
34+
* ```
35+
*
36+
* Note: Unline the `TextFieldWithMessage` and `TextAreaWithMessage`, the error
37+
* icon will do nothing for this component unless the disableVisibility`` prop
38+
* is enabled.`
39+
*/
40+
export const PasswordWithMessage = forwardRef<
41+
HTMLInputElement,
42+
PasswordWithMessageProps
43+
>(function PasswordWithMessage(
44+
{ messageProps, messageContainerProps, ...props },
45+
ref
46+
): ReactElement {
47+
return (
48+
<FieldMessageContainer
49+
{...messageContainerProps}
50+
messageProps={messageProps}
51+
>
52+
<Password {...props} ref={ref} />
53+
</FieldMessageContainer>
54+
);
55+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
/* eslint-disable @typescript-eslint/no-non-null-assertion */
2+
/* eslint-disable jsx-a11y/aria-role */
3+
import React, { ReactElement } from "react";
4+
import { fireEvent, render } from "@testing-library/react";
5+
6+
import { PasswordWithMessage } from "../PasswordWithMessage";
7+
import { TextFieldHookOptions, useTextField } from "../useTextField";
8+
import { FormMessageProps } from "../FormMessage";
9+
10+
function Test({
11+
id = "field-id",
12+
messageRole,
13+
...options
14+
}: Partial<TextFieldHookOptions> & { messageRole?: "alert" }): ReactElement {
15+
// first arg is value, but it isn't needed for these examples
16+
const [, fieldProps] = useTextField({
17+
id,
18+
...options,
19+
});
20+
21+
let messageProps: FormMessageProps = fieldProps.messageProps;
22+
if (messageRole && messageProps) {
23+
messageProps = { ...messageProps, role: messageRole };
24+
}
25+
26+
return (
27+
<PasswordWithMessage
28+
{...fieldProps}
29+
messageProps={messageProps}
30+
placeholder="Password"
31+
/>
32+
);
33+
}
34+
35+
describe("PasswordWithMessage", () => {
36+
it("should work with all the defaults when only an id is provided", () => {
37+
const { container, getByPlaceholderText, getByRole } = render(<Test />);
38+
39+
expect(container).toMatchSnapshot();
40+
const field = getByPlaceholderText("Password") as HTMLInputElement;
41+
expect(() => getByRole("alert")).toThrow();
42+
43+
expect(field).toHaveAttribute("aria-describedby", "field-id-message");
44+
expect(field.value).toBe("");
45+
46+
fireEvent.change(field, { target: { value: "a" } });
47+
expect(field.value).toBe("a");
48+
});
49+
50+
it("should apply all of the constraint props to the Password", () => {
51+
const { rerender, getByPlaceholderText } = render(
52+
<Test pattern="\d{5,8}" minLength={5} maxLength={8} required />
53+
);
54+
55+
const field = getByPlaceholderText("Password");
56+
expect(field).toHaveAttribute("minLength", "5");
57+
expect(field).toHaveAttribute("maxLength", "8");
58+
expect(field).toHaveAttribute("pattern", "\\d{5,8}");
59+
expect(field).toHaveAttribute("required");
60+
61+
rerender(<Test pattern="\d{5,8}" minLength={5} maxLength={8} />);
62+
expect(field).toHaveAttribute("minLength", "5");
63+
expect(field).toHaveAttribute("maxLength", "8");
64+
expect(field).toHaveAttribute("pattern", "\\d{5,8}");
65+
expect(field).not.toHaveAttribute("required");
66+
});
67+
68+
it("should render the counter parts in the message when the counter option is enabled along with the maxLength", () => {
69+
const props = {
70+
counter: true,
71+
maxLength: 20,
72+
};
73+
74+
const { rerender, getByPlaceholderText, getByRole } = render(
75+
<Test {...props} messageRole="alert" />
76+
);
77+
const field = getByPlaceholderText("Password");
78+
const message = getByRole("alert");
79+
80+
expect(field).toHaveAttribute("maxLength", "20");
81+
expect(message.textContent).toBe("0 / 20");
82+
83+
const value = "Hello, world!";
84+
fireEvent.change(field, { target: { value } });
85+
expect(message.textContent).toBe(`${value.length} / 20`);
86+
87+
rerender(<Test {...props} counter={false} />);
88+
expect(field).toHaveAttribute("maxLength", "20");
89+
expect(message.textContent).toBe("");
90+
});
91+
92+
it("should allow for the maxLength attribute to not be passed to the Password", () => {
93+
const props = {
94+
maxLength: 20,
95+
disableMaxLength: true,
96+
};
97+
98+
const { rerender, getByPlaceholderText } = render(<Test {...props} />);
99+
100+
const field = getByPlaceholderText("Password");
101+
expect(field).not.toHaveAttribute("maxLength");
102+
103+
rerender(<Test {...props} disableMaxLength={false} />);
104+
expect(field).toHaveAttribute("maxLength", "20");
105+
});
106+
107+
it("should enable the error state if the value is less than the minLength or more than the maxLength", () => {
108+
const { getByPlaceholderText, getByRole } = render(
109+
<Test minLength={5} maxLength={20} messageRole="alert" />
110+
);
111+
const field = getByPlaceholderText("Password");
112+
const container = field.parentElement!;
113+
const message = getByRole("alert");
114+
115+
expect(container.className).not.toContain("--error");
116+
expect(message.className).not.toContain("--error");
117+
118+
fireEvent.change(field, { target: { value: "1" } });
119+
expect(container.className).toContain("--error");
120+
expect(message.className).toContain("--error");
121+
122+
fireEvent.change(field, { target: { value: "Valid" } });
123+
expect(container.className).not.toContain("--error");
124+
expect(message.className).not.toContain("--error");
125+
126+
fireEvent.change(field, { target: { value: "1234567890123456789" } });
127+
expect(container.className).not.toContain("--error");
128+
expect(message.className).not.toContain("--error");
129+
130+
fireEvent.change(field, { target: { value: "12345678901234567890" } });
131+
expect(container.className).not.toContain("--error");
132+
expect(message.className).not.toContain("--error");
133+
134+
fireEvent.change(field, { target: { value: "123456789012345678901" } });
135+
expect(container.className).toContain("--error");
136+
expect(message.className).toContain("--error");
137+
});
138+
139+
it("should not update the error state on change or update the value if the custon onChange event stopped propagation", () => {
140+
const { getByPlaceholderText, getByRole } = render(
141+
<Test
142+
minLength={10}
143+
onChange={(event) => event.stopPropagation()}
144+
messageRole="alert"
145+
/>
146+
);
147+
148+
const field = getByPlaceholderText("Password");
149+
const container = field.parentElement!;
150+
const message = getByRole("alert");
151+
expect(container.className).not.toContain("--error");
152+
expect(message.className).not.toContain("--error");
153+
154+
fireEvent.change(field, { target: { value: "1" } });
155+
expect(field).toHaveAttribute("value", "");
156+
expect(container.className).not.toContain("--error");
157+
expect(message.className).not.toContain("--error");
158+
});
159+
160+
it("should not update the error state on change if `validateOnChange` is false", () => {
161+
const { getByPlaceholderText, getByRole } = render(
162+
<Test minLength={10} validateOnChange={false} messageRole="alert" />
163+
);
164+
165+
const field = getByPlaceholderText("Password");
166+
const container = field.parentElement!;
167+
const message = getByRole("alert");
168+
expect(container.className).not.toContain("--error");
169+
expect(message.className).not.toContain("--error");
170+
171+
fireEvent.change(field, { target: { value: "1" } });
172+
expect(field).toHaveAttribute("value", "1");
173+
expect(container.className).not.toContain("--error");
174+
expect(message.className).not.toContain("--error");
175+
});
176+
177+
it("should not update the error state on change if `validateOnChange` is an empty array", () => {
178+
const { getByPlaceholderText, getByRole } = render(
179+
<Test minLength={10} validateOnChange={[]} messageRole="alert" />
180+
);
181+
182+
const field = getByPlaceholderText("Password");
183+
const container = field.parentElement!;
184+
const message = getByRole("alert");
185+
expect(container.className).not.toContain("--error");
186+
expect(message.className).not.toContain("--error");
187+
188+
fireEvent.change(field, { target: { value: "1" } });
189+
expect(container.className).not.toContain("--error");
190+
expect(message.className).not.toContain("--error");
191+
expect(field).toHaveAttribute("value", "1");
192+
});
193+
194+
it("should render the helpText if when there is no error text", () => {
195+
const { getByPlaceholderText, getByRole } = render(
196+
<Test
197+
helpText="Help Text"
198+
messageRole="alert"
199+
maxLength={5}
200+
getErrorMessage={({ value }) =>
201+
value.length > 0 ? "Error Message" : ""
202+
}
203+
/>
204+
);
205+
206+
const field = getByPlaceholderText("Password");
207+
const container = field.parentElement!;
208+
const message = getByRole("alert");
209+
210+
expect(message.textContent).toBe("Help Text");
211+
expect(container.className).not.toContain("--error");
212+
expect(message.className).not.toContain("--error");
213+
214+
fireEvent.change(field, { target: { value: "Invalid" } });
215+
expect(message.textContent).toBe("Error Message");
216+
expect(container.className).toContain("--error");
217+
expect(message.className).toContain("--error");
218+
});
219+
220+
it.todo(
221+
"should verify the constraint validation, but it requires a real browser to work"
222+
);
223+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`PasswordWithMessage should work with all the defaults when only an id is provided 1`] = `
4+
<div>
5+
<div
6+
class="rmd-field-message-container"
7+
>
8+
<div
9+
class="rmd-text-field-container rmd-text-field-container--outline rmd-text-field-container--hoverable rmd-text-field-container--outline-right rmd-password rmd-password--offset"
10+
>
11+
<input
12+
aria-describedby="field-id-message"
13+
class="rmd-text-field rmd-password__input rmd-password__input--offset"
14+
id="field-id"
15+
placeholder="Password"
16+
type="password"
17+
value=""
18+
/>
19+
<button
20+
aria-label="Show password"
21+
aria-pressed="false"
22+
class="rmd-button rmd-button--icon rmd-password__toggle"
23+
id="field-id-password-toggle"
24+
type="button"
25+
>
26+
<i
27+
aria-hidden="true"
28+
class="rmd-icon rmd-icon--font material-icons"
29+
>
30+
remove_red_eye
31+
</i>
32+
<span
33+
class="rmd-ripple-container"
34+
/>
35+
</button>
36+
</div>
37+
<div
38+
aria-live="polite"
39+
class="rmd-form-message rmd-form-message--outline"
40+
id="field-id-message"
41+
/>
42+
</div>
43+
</div>
44+
`;

0 commit comments

Comments
 (0)