-
-
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 TextAreaWithMessage component to be used with use…
…TextField Hook
- Loading branch information
Showing
4 changed files
with
349 additions
and
0 deletions.
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,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
262
packages/form/src/text-field/__tests__/TextAreaWithMessage.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,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" | ||
); | ||
}); |
37 changes: 37 additions & 0 deletions
37
packages/form/src/text-field/__tests__/__snapshots__/TextAreaWithMessage.tsx.snap
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,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> | ||
`; |
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