Skip to content

Commit

Permalink
feat(form): added a TextAreaWithMessage component to be used with use…
Browse files Browse the repository at this point in the history
…TextField Hook
  • Loading branch information
mlaursen committed Nov 22, 2020
1 parent f2d7e5d commit e358799
Show file tree
Hide file tree
Showing 4 changed files with 349 additions and 0 deletions.
49 changes: 49 additions & 0 deletions packages/form/src/text-field/TextAreaWithMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import React, { forwardRef, ReactElement } from "react";
import {
FieldMessageContainerExtension,
FieldMessageContainer,
} from "./FieldMessageContainer";

import { TextArea, TextAreaProps } from "./TextArea";

export type TextAreaWithMessageProps = FieldMessageContainerExtension<
TextAreaProps
>;

/**
* This component is a simple wrapper for the `TextArea` and `FormMessage`
* components that should be used along with the `useTextField` hook to
* conditionally show help and error messages with a `TextArea`.
*
* Simple example:
*
* ```ts
* const [value, areaProps] = useTextField({
* id: "area-id",
* });
*
* return (
* <TextFieldWithMessage
* label="Label"
* placeholder="Placeholder"
* {...areaProps}
* />
* );
* ```
*/
export const TextAreaWithMessage = forwardRef<
HTMLTextAreaElement,
TextAreaWithMessageProps
>(function TextAreaWithMessage(
{ messageProps, messageContainerProps, ...props },
ref
): ReactElement {
return (
<FieldMessageContainer
{...messageContainerProps}
messageProps={messageProps}
>
<TextArea {...props} ref={ref} />
</FieldMessageContainer>
);
});
262 changes: 262 additions & 0 deletions packages/form/src/text-field/__tests__/TextAreaWithMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable jsx-a11y/aria-role */
import React, { ReactElement } from "react";
import { fireEvent, render } from "@testing-library/react";
import { IconProvider } from "@react-md/icon";
import { ErrorOutlineFontIcon } from "@react-md/material-icons";

import { TextAreaWithMessage } from "../TextAreaWithMessage";
import { TextFieldHookOptions, useTextField } from "../useTextField";
import { FormMessageProps } from "../FormMessage";

function Test({
id = "field-id",
messageRole,
...options
}: Partial<TextFieldHookOptions> & { messageRole?: "alert" }): ReactElement {
// first arg is value, but it isn't needed for these examples
const [, fieldProps] = useTextField({
id,
...options,
});

let messageProps: FormMessageProps = fieldProps.messageProps;
if (messageRole && messageProps) {
messageProps = { ...messageProps, role: messageRole };
}

return <TextAreaWithMessage {...fieldProps} messageProps={messageProps} />;
}

describe("TextAreaWithMessage", () => {
it("should work with all the defaults when only an id is provided", () => {
const { container, getByRole } = render(<Test />);

expect(container).toMatchSnapshot();
const field = getByRole("textbox");
expect(() => getByRole("alert")).toThrow();

expect(field).toHaveAttribute("aria-describedby", "field-id-message");
expect(field.textContent).toBe("");
/* expect(field.value).toBe(""); */

fireEvent.change(field, { target: { value: "a" } });
expect(field.textContent).toBe("a");
});

it("should apply all of the constraint props to the TextArea", () => {
const { rerender, getByRole } = render(
<Test minLength={5} maxLength={8} required />
);

const field = getByRole("textbox");
expect(field).toHaveAttribute("minLength", "5");
expect(field).toHaveAttribute("maxLength", "8");
expect(field).toHaveAttribute("required");

rerender(<Test minLength={5} maxLength={8} />);
expect(field).toHaveAttribute("minLength", "5");
expect(field).toHaveAttribute("maxLength", "8");
expect(field).not.toHaveAttribute("required");
});

it("should render the counter parts in the message when the counter option is enabled along with the maxLength", () => {
const props = {
counter: true,
maxLength: 20,
};

const { rerender, getByRole } = render(
<Test {...props} messageRole="alert" />
);
const field = getByRole("textbox");
const message = getByRole("alert");

expect(field).toHaveAttribute("maxLength", "20");
expect(message.textContent).toBe("0 / 20");

const value = "Hello, world!";
fireEvent.change(field, { target: { value } });
expect(message.textContent).toBe(`${value.length} / 20`);

rerender(<Test {...props} counter={false} />);
expect(field).toHaveAttribute("maxLength", "20");
expect(message.textContent).toBe("");
});

it("should allow for the maxLength attribute to not be passed to the TextArea", () => {
const props = {
maxLength: 20,
disableMaxLength: true,
};

const { rerender, getByRole } = render(<Test {...props} />);

const field = getByRole("textbox");
expect(field).not.toHaveAttribute("maxLength");

rerender(<Test {...props} disableMaxLength={false} />);
expect(field).toHaveAttribute("maxLength", "20");
});

it("should enable the error state if the value is less than the minLength or more than the maxLength", () => {
const { getByRole } = render(
<Test minLength={5} maxLength={20} messageRole="alert" />
);
const field = getByRole("textbox");
const container = field.parentElement!.parentElement!;
const message = getByRole("alert");

expect(container.className).not.toContain("--error");
expect(message.className).not.toContain("--error");

fireEvent.change(field, { target: { value: "1" } });
expect(container.className).toContain("--error");
expect(message.className).toContain("--error");

fireEvent.change(field, { target: { value: "Valid" } });
expect(container.className).not.toContain("--error");
expect(message.className).not.toContain("--error");

fireEvent.change(field, { target: { value: "1234567890123456789" } });
expect(container.className).not.toContain("--error");
expect(message.className).not.toContain("--error");

fireEvent.change(field, { target: { value: "12345678901234567890" } });
expect(container.className).not.toContain("--error");
expect(message.className).not.toContain("--error");

fireEvent.change(field, { target: { value: "123456789012345678901" } });
expect(container.className).toContain("--error");
expect(message.className).toContain("--error");
});

it("should not update the error state on change or update the value if the custon onChange event stopped propagation", () => {
const { getByRole } = render(
<Test
minLength={10}
onChange={(event) => event.stopPropagation()}
messageRole="alert"
/>
);

const field = getByRole("textbox");
const container = field.parentElement!.parentElement!;
const message = getByRole("alert");
expect(container.className).not.toContain("--error");
expect(message.className).not.toContain("--error");

fireEvent.change(field, { target: { value: "1" } });
expect(field.textContent).toBe("");
expect(container.className).not.toContain("--error");
expect(message.className).not.toContain("--error");
});

it("should not update the error state on change if `validateOnChange` is false", () => {
const { getByRole } = render(
<Test minLength={10} validateOnChange={false} messageRole="alert" />
);

const field = getByRole("textbox");
const container = field.parentElement!.parentElement!;
const message = getByRole("alert");
expect(container.className).not.toContain("--error");
expect(message.className).not.toContain("--error");

fireEvent.change(field, { target: { value: "1" } });
expect(field.textContent).toBe("1");
expect(container.className).not.toContain("--error");
expect(message.className).not.toContain("--error");
});

it("should not update the error state on change if `validateOnChange` is an empty array", () => {
const { getByRole } = render(
<Test minLength={10} validateOnChange={[]} messageRole="alert" />
);

const field = getByRole("textbox");
const container = field.parentElement!.parentElement!;
const message = getByRole("alert");
expect(container.className).not.toContain("--error");
expect(message.className).not.toContain("--error");

fireEvent.change(field, { target: { value: "1" } });
expect(container.className).not.toContain("--error");
expect(message.className).not.toContain("--error");
expect(field.textContent).toBe("1");
});

it("should render the helpText if when there is no error text", () => {
const { getByRole } = render(
<Test
helpText="Help Text"
messageRole="alert"
maxLength={5}
getErrorMessage={({ value }) =>
value.length > 0 ? "Error Message" : ""
}
/>
);

const field = getByRole("textbox");
const container = field.parentElement!.parentElement!;
const message = getByRole("alert");

expect(message.textContent).toBe("Help Text");
expect(container.className).not.toContain("--error");
expect(message.className).not.toContain("--error");

fireEvent.change(field, { target: { value: "Invalid" } });
expect(message.textContent).toBe("Error Message");
expect(container.className).toContain("--error");
expect(message.className).toContain("--error");
});

it("should render an icon next to the text field when there is an error by default", () => {
const { getByRole, getByText } = render(
<Test minLength={10} errorIcon={<ErrorOutlineFontIcon />} />
);
const field = getByRole("textbox");

expect(() => getByText("error_outline")).toThrow();
fireEvent.change(field, { target: { value: "Invalid" } });
expect(() => getByText("error_outline")).not.toThrow();
});

it("should default to the icon from the IconProvider", () => {
const { getByText, getByRole } = render(
<IconProvider>
<Test minLength={10} />
</IconProvider>
);
const field = getByRole("textbox");

expect(() => getByText("error_outline")).toThrow();
fireEvent.change(field, { target: { value: "Invalid" } });
expect(() => getByText("error_outline")).not.toThrow();
});

it("should override the IconProvider error icon when the errorIcon prop is defined", () => {
const { getByRole, getByText, rerender } = render(
<IconProvider>
<Test minLength={10} errorIcon={null} />
</IconProvider>
);
const field = getByRole("textbox");

expect(() => getByText("error_outline")).toThrow();
fireEvent.change(field, { target: { value: "Invalid" } });
expect(() => getByText("error_outline")).toThrow();

rerender(
<IconProvider>
<Test minLength={10} errorIcon={<span>My Icon!</span>} />
</IconProvider>
);
expect(() => getByText("My Icon!")).not.toThrow();
});

it.todo(
"should verify the constraint validation, but it requires a real browser to work"
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`TextAreaWithMessage should work with all the defaults when only an id is provided 1`] = `
<div>
<div
class="rmd-field-message-container"
>
<div
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"
>
<div
class="rmd-textarea-container__inner rmd-textarea-container__inner--animate"
>
<textarea
aria-describedby="field-id-message"
class="rmd-textarea rmd-textarea--rn"
id="field-id"
rows="2"
/>
<textarea
aria-hidden="true"
class="rmd-textarea rmd-textarea--rn rmd-textarea--mask"
id="field-id-mask"
readonly=""
rows="2"
tabindex="-1"
/>
</div>
</div>
<div
aria-live="polite"
class="rmd-form-message rmd-form-message--outline"
id="field-id-message"
/>
</div>
</div>
`;
1 change: 1 addition & 0 deletions packages/form/src/text-field/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from "./TextArea";
export * from "./FormMessage";
export * from "./FieldMessageContainer";
export * from "./TextFieldWithMessage";
export * from "./TextAreaWithMessage";

export * from "./isErrored";
export * from "./getErrorIcon";
Expand Down

0 comments on commit e358799

Please sign in to comment.