Skip to content

Commit e358799

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

4 files changed

Lines changed: 349 additions & 0 deletions

File tree

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+
import {
3+
FieldMessageContainerExtension,
4+
FieldMessageContainer,
5+
} from "./FieldMessageContainer";
6+
7+
import { TextArea, TextAreaProps } from "./TextArea";
8+
9+
export type TextAreaWithMessageProps = FieldMessageContainerExtension<
10+
TextAreaProps
11+
>;
12+
13+
/**
14+
* This component is a simple wrapper for the `TextArea` and `FormMessage`
15+
* components that should be used along with the `useTextField` hook to
16+
* conditionally show help and error messages with a `TextArea`.
17+
*
18+
* Simple example:
19+
*
20+
* ```ts
21+
* const [value, areaProps] = useTextField({
22+
* id: "area-id",
23+
* });
24+
*
25+
* return (
26+
* <TextFieldWithMessage
27+
* label="Label"
28+
* placeholder="Placeholder"
29+
* {...areaProps}
30+
* />
31+
* );
32+
* ```
33+
*/
34+
export const TextAreaWithMessage = forwardRef<
35+
HTMLTextAreaElement,
36+
TextAreaWithMessageProps
37+
>(function TextAreaWithMessage(
38+
{ messageProps, messageContainerProps, ...props },
39+
ref
40+
): ReactElement {
41+
return (
42+
<FieldMessageContainer
43+
{...messageContainerProps}
44+
messageProps={messageProps}
45+
>
46+
<TextArea {...props} ref={ref} />
47+
</FieldMessageContainer>
48+
);
49+
});
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
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 { TextAreaWithMessage } from "../TextAreaWithMessage";
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 <TextAreaWithMessage {...fieldProps} messageProps={messageProps} />;
29+
}
30+
31+
describe("TextAreaWithMessage", () => {
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");
37+
expect(() => getByRole("alert")).toThrow();
38+
39+
expect(field).toHaveAttribute("aria-describedby", "field-id-message");
40+
expect(field.textContent).toBe("");
41+
/* expect(field.value).toBe(""); */
42+
43+
fireEvent.change(field, { target: { value: "a" } });
44+
expect(field.textContent).toBe("a");
45+
});
46+
47+
it("should apply all of the constraint props to the TextArea", () => {
48+
const { rerender, getByRole } = render(
49+
<Test minLength={5} maxLength={8} required />
50+
);
51+
52+
const field = getByRole("textbox");
53+
expect(field).toHaveAttribute("minLength", "5");
54+
expect(field).toHaveAttribute("maxLength", "8");
55+
expect(field).toHaveAttribute("required");
56+
57+
rerender(<Test minLength={5} maxLength={8} />);
58+
expect(field).toHaveAttribute("minLength", "5");
59+
expect(field).toHaveAttribute("maxLength", "8");
60+
expect(field).not.toHaveAttribute("required");
61+
});
62+
63+
it("should render the counter parts in the message when the counter option is enabled along with the maxLength", () => {
64+
const props = {
65+
counter: true,
66+
maxLength: 20,
67+
};
68+
69+
const { rerender, getByRole } = render(
70+
<Test {...props} messageRole="alert" />
71+
);
72+
const field = getByRole("textbox");
73+
const message = getByRole("alert");
74+
75+
expect(field).toHaveAttribute("maxLength", "20");
76+
expect(message.textContent).toBe("0 / 20");
77+
78+
const value = "Hello, world!";
79+
fireEvent.change(field, { target: { value } });
80+
expect(message.textContent).toBe(`${value.length} / 20`);
81+
82+
rerender(<Test {...props} counter={false} />);
83+
expect(field).toHaveAttribute("maxLength", "20");
84+
expect(message.textContent).toBe("");
85+
});
86+
87+
it("should allow for the maxLength attribute to not be passed to the TextArea", () => {
88+
const props = {
89+
maxLength: 20,
90+
disableMaxLength: true,
91+
};
92+
93+
const { rerender, getByRole } = render(<Test {...props} />);
94+
95+
const field = getByRole("textbox");
96+
expect(field).not.toHaveAttribute("maxLength");
97+
98+
rerender(<Test {...props} disableMaxLength={false} />);
99+
expect(field).toHaveAttribute("maxLength", "20");
100+
});
101+
102+
it("should enable the error state if the value is less than the minLength or more than the maxLength", () => {
103+
const { getByRole } = render(
104+
<Test minLength={5} maxLength={20} messageRole="alert" />
105+
);
106+
const field = getByRole("textbox");
107+
const container = field.parentElement!.parentElement!;
108+
const message = getByRole("alert");
109+
110+
expect(container.className).not.toContain("--error");
111+
expect(message.className).not.toContain("--error");
112+
113+
fireEvent.change(field, { target: { value: "1" } });
114+
expect(container.className).toContain("--error");
115+
expect(message.className).toContain("--error");
116+
117+
fireEvent.change(field, { target: { value: "Valid" } });
118+
expect(container.className).not.toContain("--error");
119+
expect(message.className).not.toContain("--error");
120+
121+
fireEvent.change(field, { target: { value: "1234567890123456789" } });
122+
expect(container.className).not.toContain("--error");
123+
expect(message.className).not.toContain("--error");
124+
125+
fireEvent.change(field, { target: { value: "12345678901234567890" } });
126+
expect(container.className).not.toContain("--error");
127+
expect(message.className).not.toContain("--error");
128+
129+
fireEvent.change(field, { target: { value: "123456789012345678901" } });
130+
expect(container.className).toContain("--error");
131+
expect(message.className).toContain("--error");
132+
});
133+
134+
it("should not update the error state on change or update the value if the custon onChange event stopped propagation", () => {
135+
const { getByRole } = render(
136+
<Test
137+
minLength={10}
138+
onChange={(event) => event.stopPropagation()}
139+
messageRole="alert"
140+
/>
141+
);
142+
143+
const field = getByRole("textbox");
144+
const container = field.parentElement!.parentElement!;
145+
const message = getByRole("alert");
146+
expect(container.className).not.toContain("--error");
147+
expect(message.className).not.toContain("--error");
148+
149+
fireEvent.change(field, { target: { value: "1" } });
150+
expect(field.textContent).toBe("");
151+
expect(container.className).not.toContain("--error");
152+
expect(message.className).not.toContain("--error");
153+
});
154+
155+
it("should not update the error state on change if `validateOnChange` is false", () => {
156+
const { getByRole } = render(
157+
<Test minLength={10} validateOnChange={false} messageRole="alert" />
158+
);
159+
160+
const field = getByRole("textbox");
161+
const container = field.parentElement!.parentElement!;
162+
const message = getByRole("alert");
163+
expect(container.className).not.toContain("--error");
164+
expect(message.className).not.toContain("--error");
165+
166+
fireEvent.change(field, { target: { value: "1" } });
167+
expect(field.textContent).toBe("1");
168+
expect(container.className).not.toContain("--error");
169+
expect(message.className).not.toContain("--error");
170+
});
171+
172+
it("should not update the error state on change if `validateOnChange` is an empty array", () => {
173+
const { getByRole } = render(
174+
<Test minLength={10} validateOnChange={[]} messageRole="alert" />
175+
);
176+
177+
const field = getByRole("textbox");
178+
const container = field.parentElement!.parentElement!;
179+
const message = getByRole("alert");
180+
expect(container.className).not.toContain("--error");
181+
expect(message.className).not.toContain("--error");
182+
183+
fireEvent.change(field, { target: { value: "1" } });
184+
expect(container.className).not.toContain("--error");
185+
expect(message.className).not.toContain("--error");
186+
expect(field.textContent).toBe("1");
187+
});
188+
189+
it("should render the helpText if when there is no error text", () => {
190+
const { getByRole } = render(
191+
<Test
192+
helpText="Help Text"
193+
messageRole="alert"
194+
maxLength={5}
195+
getErrorMessage={({ value }) =>
196+
value.length > 0 ? "Error Message" : ""
197+
}
198+
/>
199+
);
200+
201+
const field = getByRole("textbox");
202+
const container = field.parentElement!.parentElement!;
203+
const message = getByRole("alert");
204+
205+
expect(message.textContent).toBe("Help Text");
206+
expect(container.className).not.toContain("--error");
207+
expect(message.className).not.toContain("--error");
208+
209+
fireEvent.change(field, { target: { value: "Invalid" } });
210+
expect(message.textContent).toBe("Error Message");
211+
expect(container.className).toContain("--error");
212+
expect(message.className).toContain("--error");
213+
});
214+
215+
it("should render an icon next to the text field when there is an error by default", () => {
216+
const { getByRole, getByText } = render(
217+
<Test minLength={10} errorIcon={<ErrorOutlineFontIcon />} />
218+
);
219+
const field = getByRole("textbox");
220+
221+
expect(() => getByText("error_outline")).toThrow();
222+
fireEvent.change(field, { target: { value: "Invalid" } });
223+
expect(() => getByText("error_outline")).not.toThrow();
224+
});
225+
226+
it("should default to the icon from the IconProvider", () => {
227+
const { getByText, getByRole } = render(
228+
<IconProvider>
229+
<Test minLength={10} />
230+
</IconProvider>
231+
);
232+
const field = getByRole("textbox");
233+
234+
expect(() => getByText("error_outline")).toThrow();
235+
fireEvent.change(field, { target: { value: "Invalid" } });
236+
expect(() => getByText("error_outline")).not.toThrow();
237+
});
238+
239+
it("should override the IconProvider error icon when the errorIcon prop is defined", () => {
240+
const { getByRole, getByText, rerender } = render(
241+
<IconProvider>
242+
<Test minLength={10} errorIcon={null} />
243+
</IconProvider>
244+
);
245+
const field = getByRole("textbox");
246+
247+
expect(() => getByText("error_outline")).toThrow();
248+
fireEvent.change(field, { target: { value: "Invalid" } });
249+
expect(() => getByText("error_outline")).toThrow();
250+
251+
rerender(
252+
<IconProvider>
253+
<Test minLength={10} errorIcon={<span>My Icon!</span>} />
254+
</IconProvider>
255+
);
256+
expect(() => getByText("My Icon!")).not.toThrow();
257+
});
258+
259+
it.todo(
260+
"should verify the constraint validation, but it requires a real browser to work"
261+
);
262+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`TextAreaWithMessage 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-textarea-container rmd-textarea-container--animate rmd-textarea-container--cursor"
10+
>
11+
<div
12+
class="rmd-textarea-container__inner rmd-textarea-container__inner--animate"
13+
>
14+
<textarea
15+
aria-describedby="field-id-message"
16+
class="rmd-textarea rmd-textarea--rn"
17+
id="field-id"
18+
rows="2"
19+
/>
20+
<textarea
21+
aria-hidden="true"
22+
class="rmd-textarea rmd-textarea--rn rmd-textarea--mask"
23+
id="field-id-mask"
24+
readonly=""
25+
rows="2"
26+
tabindex="-1"
27+
/>
28+
</div>
29+
</div>
30+
<div
31+
aria-live="polite"
32+
class="rmd-form-message rmd-form-message--outline"
33+
id="field-id-message"
34+
/>
35+
</div>
36+
</div>
37+
`;

packages/form/src/text-field/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export * from "./TextArea";
77
export * from "./FormMessage";
88
export * from "./FieldMessageContainer";
99
export * from "./TextFieldWithMessage";
10+
export * from "./TextAreaWithMessage";
1011

1112
export * from "./isErrored";
1213
export * from "./getErrorIcon";

0 commit comments

Comments
 (0)