-
-
Notifications
You must be signed in to change notification settings - Fork 298
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(form): added a TextFieldWithMessage component to be used with us…
…eTextField Hook
- Loading branch information
Showing
7 changed files
with
433 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
263
packages/form/src/text-field/__tests__/TextFieldWithMessage.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
); | ||
}); |
Oops, something went wrong.