Skip to content

Commit

Permalink
feat(form): added a TextFieldWithMessage component to be used with us…
Browse files Browse the repository at this point in the history
…eTextField Hook
  • Loading branch information
mlaursen committed Nov 22, 2020
1 parent 578257c commit f2d7e5d
Show file tree
Hide file tree
Showing 7 changed files with 433 additions and 1 deletion.
76 changes: 76 additions & 0 deletions packages/form/src/text-field/FieldMessageContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import React, { forwardRef, HTMLAttributes, ReactElement } from "react";
import cn from "classnames";
import { PropsWithRef } from "@react-md/utils";
import { FormMessage, FormMessageProps } from "./FormMessage";

type DivAttributes = HTMLAttributes<HTMLDivElement>;
type MessageProps = PropsWithRef<FormMessageProps, HTMLDivElement>;
type MessageContainerProps = PropsWithRef<DivAttributes, HTMLDivElement>;

/**
* This is a utility type that allows for a component to "extend" the
* `FieldMessageContainer` component. This should really be used internally with
* any `TextField` or `TextArea` related components.
*/
export type FieldMessageContainerExtension<P, V = string> = Omit<
P,
"value" | "defaultValue"
> & {
/**
* The value will always be required for these extensions since they should be
* used with the `useTextField` hook.
*/
value: V;

/**
* If the extension doesn't actually want to render the `FormMessage`
* component, these props are optional. It kind of eliminates the whole
* purpose of this component though.
*/
messageProps?: MessageProps;

/**
* Any props (and an optional ref) to provide to the `<div>` surrounding the
* children and `FormMessage` component.
*
* Note: This will not be used if the `messageProps` are not provided since
* only the `children` will be returned without the container.
*/
messageContainerProps?: MessageContainerProps;
};

export interface FieldMessageContainerProps extends DivAttributes {
/**
* If the extension doesn't actually want to render the `FormMessage`
* component, these props are optional. It kind of eliminates the whole
* purpose of this component though.
*/
messageProps?: MessageProps;
}

/**
* A wrapper component that can be used to display a `TextField` related
* component or `TextArea` along with the `FormMessage` component.
*/
export const FieldMessageContainer = forwardRef<
HTMLDivElement,
FieldMessageContainerProps
>(function FieldMessageContainer(
{ className, children, messageProps, ...props },
ref
): ReactElement {
if (!messageProps) {
return <>{children}</>;
}

return (
<div
{...props}
ref={ref}
className={cn("rmd-field-message-container", className)}
>
{children}
<FormMessage {...messageProps} />
</div>
);
});
49 changes: 49 additions & 0 deletions packages/form/src/text-field/TextFieldWithMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import React, { forwardRef, ReactElement } from "react";

import {
FieldMessageContainer,
FieldMessageContainerExtension,
} from "./FieldMessageContainer";
import { TextField, TextFieldProps } from "./TextField";

export type TextFieldWithMessageProps = FieldMessageContainerExtension<
TextFieldProps
>;

/**
* This component is a simple wrapper for the `TextField` and `FormMessage`
* components that should be used along with the `useTextField` hook to
* conditionally show help and error messages with a `TextField`.
*
* Simple example:
*
* ```ts
* const [value, fieldProps] = useTextField({
* id: "field-id",
* });
*
* return (
* <TextFieldWithMessage
* label="Label"
* placeholder="Placeholder"
* {...fieldProps}
* />
* );
* ```
*/
export const TextFieldWithMessage = forwardRef<
HTMLInputElement,
TextFieldWithMessageProps
>(function TextFieldWithMessage(
{ messageProps, messageContainerProps, ...props },
ref
): ReactElement {
return (
<FieldMessageContainer
{...messageContainerProps}
messageProps={messageProps}
>
<TextField {...props} ref={ref} />
</FieldMessageContainer>
);
});
263 changes: 263 additions & 0 deletions packages/form/src/text-field/__tests__/TextFieldWithMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
/* 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 { TextFieldWithMessage } from "../TextFieldWithMessage";
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 <TextFieldWithMessage {...fieldProps} messageProps={messageProps} />;
}

describe("TextFieldWithMessage", () => {
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") as HTMLInputElement;
expect(() => getByRole("alert")).toThrow();

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

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

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

const field = getByRole("textbox");
expect(field).toHaveAttribute("minLength", "5");
expect(field).toHaveAttribute("maxLength", "8");
expect(field).toHaveAttribute("pattern", "\\d{5,8}");
expect(field).toHaveAttribute("required");

rerender(<Test pattern="\d{5,8}" minLength={5} maxLength={8} />);
expect(field).toHaveAttribute("minLength", "5");
expect(field).toHaveAttribute("maxLength", "8");
expect(field).toHaveAttribute("pattern", "\\d{5,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 TextField", () => {
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!;
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!;
const message = getByRole("alert");
expect(container.className).not.toContain("--error");
expect(message.className).not.toContain("--error");

fireEvent.change(field, { target: { value: "1" } });
expect(field).toHaveAttribute("value", "");
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!;
const message = getByRole("alert");
expect(container.className).not.toContain("--error");
expect(message.className).not.toContain("--error");

fireEvent.change(field, { target: { value: "1" } });
expect(field).toHaveAttribute("value", "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!;
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).toHaveAttribute("value", "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!;
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"
);
});
Loading

0 comments on commit f2d7e5d

Please sign in to comment.