Skip to content

Commit f2d7e5d

Browse files
committed
feat(form): added a TextFieldWithMessage component to be used with useTextField Hook
1 parent 578257c commit f2d7e5d

File tree

7 files changed

+433
-1
lines changed

7 files changed

+433
-1
lines changed
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import React, { forwardRef, HTMLAttributes, ReactElement } from "react";
2+
import cn from "classnames";
3+
import { PropsWithRef } from "@react-md/utils";
4+
import { FormMessage, FormMessageProps } from "./FormMessage";
5+
6+
type DivAttributes = HTMLAttributes<HTMLDivElement>;
7+
type MessageProps = PropsWithRef<FormMessageProps, HTMLDivElement>;
8+
type MessageContainerProps = PropsWithRef<DivAttributes, HTMLDivElement>;
9+
10+
/**
11+
* This is a utility type that allows for a component to "extend" the
12+
* `FieldMessageContainer` component. This should really be used internally with
13+
* any `TextField` or `TextArea` related components.
14+
*/
15+
export type FieldMessageContainerExtension<P, V = string> = Omit<
16+
P,
17+
"value" | "defaultValue"
18+
> & {
19+
/**
20+
* The value will always be required for these extensions since they should be
21+
* used with the `useTextField` hook.
22+
*/
23+
value: V;
24+
25+
/**
26+
* If the extension doesn't actually want to render the `FormMessage`
27+
* component, these props are optional. It kind of eliminates the whole
28+
* purpose of this component though.
29+
*/
30+
messageProps?: MessageProps;
31+
32+
/**
33+
* Any props (and an optional ref) to provide to the `<div>` surrounding the
34+
* children and `FormMessage` component.
35+
*
36+
* Note: This will not be used if the `messageProps` are not provided since
37+
* only the `children` will be returned without the container.
38+
*/
39+
messageContainerProps?: MessageContainerProps;
40+
};
41+
42+
export interface FieldMessageContainerProps extends DivAttributes {
43+
/**
44+
* If the extension doesn't actually want to render the `FormMessage`
45+
* component, these props are optional. It kind of eliminates the whole
46+
* purpose of this component though.
47+
*/
48+
messageProps?: MessageProps;
49+
}
50+
51+
/**
52+
* A wrapper component that can be used to display a `TextField` related
53+
* component or `TextArea` along with the `FormMessage` component.
54+
*/
55+
export const FieldMessageContainer = forwardRef<
56+
HTMLDivElement,
57+
FieldMessageContainerProps
58+
>(function FieldMessageContainer(
59+
{ className, children, messageProps, ...props },
60+
ref
61+
): ReactElement {
62+
if (!messageProps) {
63+
return <>{children}</>;
64+
}
65+
66+
return (
67+
<div
68+
{...props}
69+
ref={ref}
70+
className={cn("rmd-field-message-container", className)}
71+
>
72+
{children}
73+
<FormMessage {...messageProps} />
74+
</div>
75+
);
76+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import React, { forwardRef, ReactElement } from "react";
2+
3+
import {
4+
FieldMessageContainer,
5+
FieldMessageContainerExtension,
6+
} from "./FieldMessageContainer";
7+
import { TextField, TextFieldProps } from "./TextField";
8+
9+
export type TextFieldWithMessageProps = FieldMessageContainerExtension<
10+
TextFieldProps
11+
>;
12+
13+
/**
14+
* This component is a simple wrapper for the `TextField` and `FormMessage`
15+
* components that should be used along with the `useTextField` hook to
16+
* conditionally show help and error messages with a `TextField`.
17+
*
18+
* Simple example:
19+
*
20+
* ```ts
21+
* const [value, fieldProps] = useTextField({
22+
* id: "field-id",
23+
* });
24+
*
25+
* return (
26+
* <TextFieldWithMessage
27+
* label="Label"
28+
* placeholder="Placeholder"
29+
* {...fieldProps}
30+
* />
31+
* );
32+
* ```
33+
*/
34+
export const TextFieldWithMessage = forwardRef<
35+
HTMLInputElement,
36+
TextFieldWithMessageProps
37+
>(function TextFieldWithMessage(
38+
{ messageProps, messageContainerProps, ...props },
39+
ref
40+
): ReactElement {
41+
return (
42+
<FieldMessageContainer
43+
{...messageContainerProps}
44+
messageProps={messageProps}
45+
>
46+
<TextField {...props} ref={ref} />
47+
</FieldMessageContainer>
48+
);
49+
});
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
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+
import { IconProvider } from "@react-md/icon";
6+
import { ErrorOutlineFontIcon } from "@react-md/material-icons";
7+
8+
import { TextFieldWithMessage } from "../TextFieldWithMessage";
9+
import { TextFieldHookOptions, useTextField } from "../useTextField";
10+
import { FormMessageProps } from "../FormMessage";
11+
12+
function Test({
13+
id = "field-id",
14+
messageRole,
15+
...options
16+
}: Partial<TextFieldHookOptions> & { messageRole?: "alert" }): ReactElement {
17+
// first arg is value, but it isn't needed for these examples
18+
const [, fieldProps] = useTextField({
19+
id,
20+
...options,
21+
});
22+
23+
let messageProps: FormMessageProps = fieldProps.messageProps;
24+
if (messageRole && messageProps) {
25+
messageProps = { ...messageProps, role: messageRole };
26+
}
27+
28+
return <TextFieldWithMessage {...fieldProps} messageProps={messageProps} />;
29+
}
30+
31+
describe("TextFieldWithMessage", () => {
32+
it("should work with all the defaults when only an id is provided", () => {
33+
const { container, getByRole } = render(<Test />);
34+
35+
expect(container).toMatchSnapshot();
36+
const field = getByRole("textbox") as HTMLInputElement;
37+
expect(() => getByRole("alert")).toThrow();
38+
39+
expect(field).toHaveAttribute("aria-describedby", "field-id-message");
40+
expect(field.value).toBe("");
41+
42+
fireEvent.change(field, { target: { value: "a" } });
43+
expect(field.value).toBe("a");
44+
});
45+
46+
it("should apply all of the constraint props to the TextField", () => {
47+
const { rerender, getByRole } = render(
48+
<Test pattern="\d{5,8}" minLength={5} maxLength={8} required />
49+
);
50+
51+
const field = getByRole("textbox");
52+
expect(field).toHaveAttribute("minLength", "5");
53+
expect(field).toHaveAttribute("maxLength", "8");
54+
expect(field).toHaveAttribute("pattern", "\\d{5,8}");
55+
expect(field).toHaveAttribute("required");
56+
57+
rerender(<Test pattern="\d{5,8}" minLength={5} maxLength={8} />);
58+
expect(field).toHaveAttribute("minLength", "5");
59+
expect(field).toHaveAttribute("maxLength", "8");
60+
expect(field).toHaveAttribute("pattern", "\\d{5,8}");
61+
expect(field).not.toHaveAttribute("required");
62+
});
63+
64+
it("should render the counter parts in the message when the counter option is enabled along with the maxLength", () => {
65+
const props = {
66+
counter: true,
67+
maxLength: 20,
68+
};
69+
70+
const { rerender, getByRole } = render(
71+
<Test {...props} messageRole="alert" />
72+
);
73+
const field = getByRole("textbox");
74+
const message = getByRole("alert");
75+
76+
expect(field).toHaveAttribute("maxLength", "20");
77+
expect(message.textContent).toBe("0 / 20");
78+
79+
const value = "Hello, world!";
80+
fireEvent.change(field, { target: { value } });
81+
expect(message.textContent).toBe(`${value.length} / 20`);
82+
83+
rerender(<Test {...props} counter={false} />);
84+
expect(field).toHaveAttribute("maxLength", "20");
85+
expect(message.textContent).toBe("");
86+
});
87+
88+
it("should allow for the maxLength attribute to not be passed to the TextField", () => {
89+
const props = {
90+
maxLength: 20,
91+
disableMaxLength: true,
92+
};
93+
94+
const { rerender, getByRole } = render(<Test {...props} />);
95+
96+
const field = getByRole("textbox");
97+
expect(field).not.toHaveAttribute("maxLength");
98+
99+
rerender(<Test {...props} disableMaxLength={false} />);
100+
expect(field).toHaveAttribute("maxLength", "20");
101+
});
102+
103+
it("should enable the error state if the value is less than the minLength or more than the maxLength", () => {
104+
const { getByRole } = render(
105+
<Test minLength={5} maxLength={20} messageRole="alert" />
106+
);
107+
const field = getByRole("textbox");
108+
const container = field.parentElement!;
109+
const message = getByRole("alert");
110+
111+
expect(container.className).not.toContain("--error");
112+
expect(message.className).not.toContain("--error");
113+
114+
fireEvent.change(field, { target: { value: "1" } });
115+
expect(container.className).toContain("--error");
116+
expect(message.className).toContain("--error");
117+
118+
fireEvent.change(field, { target: { value: "Valid" } });
119+
expect(container.className).not.toContain("--error");
120+
expect(message.className).not.toContain("--error");
121+
122+
fireEvent.change(field, { target: { value: "1234567890123456789" } });
123+
expect(container.className).not.toContain("--error");
124+
expect(message.className).not.toContain("--error");
125+
126+
fireEvent.change(field, { target: { value: "12345678901234567890" } });
127+
expect(container.className).not.toContain("--error");
128+
expect(message.className).not.toContain("--error");
129+
130+
fireEvent.change(field, { target: { value: "123456789012345678901" } });
131+
expect(container.className).toContain("--error");
132+
expect(message.className).toContain("--error");
133+
});
134+
135+
it("should not update the error state on change or update the value if the custon onChange event stopped propagation", () => {
136+
const { getByRole } = render(
137+
<Test
138+
minLength={10}
139+
onChange={(event) => event.stopPropagation()}
140+
messageRole="alert"
141+
/>
142+
);
143+
144+
const field = getByRole("textbox");
145+
const container = field.parentElement!;
146+
const message = getByRole("alert");
147+
expect(container.className).not.toContain("--error");
148+
expect(message.className).not.toContain("--error");
149+
150+
fireEvent.change(field, { target: { value: "1" } });
151+
expect(field).toHaveAttribute("value", "");
152+
expect(container.className).not.toContain("--error");
153+
expect(message.className).not.toContain("--error");
154+
});
155+
156+
it("should not update the error state on change if `validateOnChange` is false", () => {
157+
const { getByRole } = render(
158+
<Test minLength={10} validateOnChange={false} messageRole="alert" />
159+
);
160+
161+
const field = getByRole("textbox");
162+
const container = field.parentElement!;
163+
const message = getByRole("alert");
164+
expect(container.className).not.toContain("--error");
165+
expect(message.className).not.toContain("--error");
166+
167+
fireEvent.change(field, { target: { value: "1" } });
168+
expect(field).toHaveAttribute("value", "1");
169+
expect(container.className).not.toContain("--error");
170+
expect(message.className).not.toContain("--error");
171+
});
172+
173+
it("should not update the error state on change if `validateOnChange` is an empty array", () => {
174+
const { getByRole } = render(
175+
<Test minLength={10} validateOnChange={[]} messageRole="alert" />
176+
);
177+
178+
const field = getByRole("textbox");
179+
const container = field.parentElement!;
180+
const message = getByRole("alert");
181+
expect(container.className).not.toContain("--error");
182+
expect(message.className).not.toContain("--error");
183+
184+
fireEvent.change(field, { target: { value: "1" } });
185+
expect(container.className).not.toContain("--error");
186+
expect(message.className).not.toContain("--error");
187+
expect(field).toHaveAttribute("value", "1");
188+
});
189+
190+
it("should render the helpText if when there is no error text", () => {
191+
const { getByRole } = render(
192+
<Test
193+
helpText="Help Text"
194+
messageRole="alert"
195+
maxLength={5}
196+
getErrorMessage={({ value }) =>
197+
value.length > 0 ? "Error Message" : ""
198+
}
199+
/>
200+
);
201+
202+
const field = getByRole("textbox");
203+
const container = field.parentElement!;
204+
const message = getByRole("alert");
205+
206+
expect(message.textContent).toBe("Help Text");
207+
expect(container.className).not.toContain("--error");
208+
expect(message.className).not.toContain("--error");
209+
210+
fireEvent.change(field, { target: { value: "Invalid" } });
211+
expect(message.textContent).toBe("Error Message");
212+
expect(container.className).toContain("--error");
213+
expect(message.className).toContain("--error");
214+
});
215+
216+
it("should render an icon next to the text field when there is an error by default", () => {
217+
const { getByRole, getByText } = render(
218+
<Test minLength={10} errorIcon={<ErrorOutlineFontIcon />} />
219+
);
220+
const field = getByRole("textbox");
221+
222+
expect(() => getByText("error_outline")).toThrow();
223+
fireEvent.change(field, { target: { value: "Invalid" } });
224+
expect(() => getByText("error_outline")).not.toThrow();
225+
});
226+
227+
it("should default to the icon from the IconProvider", () => {
228+
const { getByText, getByRole } = render(
229+
<IconProvider>
230+
<Test minLength={10} />
231+
</IconProvider>
232+
);
233+
const field = getByRole("textbox");
234+
235+
expect(() => getByText("error_outline")).toThrow();
236+
fireEvent.change(field, { target: { value: "Invalid" } });
237+
expect(() => getByText("error_outline")).not.toThrow();
238+
});
239+
240+
it("should override the IconProvider error icon when the errorIcon prop is defined", () => {
241+
const { getByRole, getByText, rerender } = render(
242+
<IconProvider>
243+
<Test minLength={10} errorIcon={null} />
244+
</IconProvider>
245+
);
246+
const field = getByRole("textbox");
247+
248+
expect(() => getByText("error_outline")).toThrow();
249+
fireEvent.change(field, { target: { value: "Invalid" } });
250+
expect(() => getByText("error_outline")).toThrow();
251+
252+
rerender(
253+
<IconProvider>
254+
<Test minLength={10} errorIcon={<span>My Icon!</span>} />
255+
</IconProvider>
256+
);
257+
expect(() => getByText("My Icon!")).not.toThrow();
258+
});
259+
260+
it.todo(
261+
"should verify the constraint validation, but it requires a real browser to work"
262+
);
263+
});

0 commit comments

Comments
 (0)