Skip to content

Commit

Permalink
feat(form): added a PasswordWithMessage 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 e358799 commit f6d84f2
Show file tree
Hide file tree
Showing 5 changed files with 347 additions and 20 deletions.
44 changes: 24 additions & 20 deletions packages/form/src/text-field/Password.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,7 @@ export interface ConfigurableVisibilityIcon {

export type GetVisibilityIcon = (type: "text" | "password") => ReactNode;

export interface PasswordProps
extends Omit<TextFieldProps, "type" | "rightChildren"> {
export interface PasswordProps extends Omit<TextFieldProps, "type"> {
/**
* The icon to use to toggle the visibility of the password by changing the
* input type to text temporarily. This can either be a renderable React node
Expand Down Expand Up @@ -108,6 +107,8 @@ export const Password = forwardRef<HTMLInputElement, PasswordProps>(
onVisibilityClick,
getVisibilityIcon,
disableVisibility = false,
rightChildren: propRightChildren,
isRightAddon = disableVisibility,
...props
},
ref
Expand All @@ -133,6 +134,25 @@ export const Password = forwardRef<HTMLInputElement, PasswordProps>(
: propVisibilityIcon.invisible;
}

let rightChildren: ReactNode = propRightChildren;
if (!disableVisibility) {
rightChildren = (
<Button
id={`${id}-password-toggle`}
aria-label={visibilityLabel}
aria-pressed={visible}
buttonType="icon"
onClick={toggle}
style={visibilityStyle}
className={cn(block("toggle"), visibilityClassName)}
>
{typeof getVisibilityIcon === "function"
? getVisibilityIcon(type)
: visibilityIcon}
</Button>
);
}

return (
<TextField
{...props}
Expand All @@ -143,24 +163,8 @@ export const Password = forwardRef<HTMLInputElement, PasswordProps>(
)}
ref={ref}
type={type}
isRightAddon={false}
rightChildren={
!disableVisibility && (
<Button
id={`${id}-password-toggle`}
aria-label={visibilityLabel}
aria-pressed={visible}
buttonType="icon"
onClick={toggle}
style={visibilityStyle}
className={cn(block("toggle"), visibilityClassName)}
>
{typeof getVisibilityIcon === "function"
? getVisibilityIcon(type)
: visibilityIcon}
</Button>
)
}
isRightAddon={isRightAddon}
rightChildren={rightChildren}
/>
);
}
Expand Down
55 changes: 55 additions & 0 deletions packages/form/src/text-field/PasswordWithMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React, { forwardRef, ReactElement } from "react";
import {
FieldMessageContainerExtension,
FieldMessageContainer,
} from "./FieldMessageContainer";

import { Password, PasswordProps } from "./Password";

export type PasswordWithMessageProps = FieldMessageContainerExtension<
PasswordProps
>;

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

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

expect(container).toMatchSnapshot();
const field = getByPlaceholderText("Password") 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 Password", () => {
const { rerender, getByPlaceholderText } = render(
<Test pattern="\d{5,8}" minLength={5} maxLength={8} required />
);

const field = getByPlaceholderText("Password");
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, getByPlaceholderText, getByRole } = render(
<Test {...props} messageRole="alert" />
);
const field = getByPlaceholderText("Password");
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 Password", () => {
const props = {
maxLength: 20,
disableMaxLength: true,
};

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

const field = getByPlaceholderText("Password");
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 { getByPlaceholderText, getByRole } = render(
<Test minLength={5} maxLength={20} messageRole="alert" />
);
const field = getByPlaceholderText("Password");
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 { getByPlaceholderText, getByRole } = render(
<Test
minLength={10}
onChange={(event) => event.stopPropagation()}
messageRole="alert"
/>
);

const field = getByPlaceholderText("Password");
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 { getByPlaceholderText, getByRole } = render(
<Test minLength={10} validateOnChange={false} messageRole="alert" />
);

const field = getByPlaceholderText("Password");
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 { getByPlaceholderText, getByRole } = render(
<Test minLength={10} validateOnChange={[]} messageRole="alert" />
);

const field = getByPlaceholderText("Password");
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 { getByPlaceholderText, getByRole } = render(
<Test
helpText="Help Text"
messageRole="alert"
maxLength={5}
getErrorMessage={({ value }) =>
value.length > 0 ? "Error Message" : ""
}
/>
);

const field = getByPlaceholderText("Password");
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.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,44 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`PasswordWithMessage 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-text-field-container--outline-right rmd-password rmd-password--offset"
>
<input
aria-describedby="field-id-message"
class="rmd-text-field rmd-password__input rmd-password__input--offset"
id="field-id"
placeholder="Password"
type="password"
value=""
/>
<button
aria-label="Show password"
aria-pressed="false"
class="rmd-button rmd-button--icon rmd-password__toggle"
id="field-id-password-toggle"
type="button"
>
<i
aria-hidden="true"
class="rmd-icon rmd-icon--font material-icons"
>
remove_red_eye
</i>
<span
class="rmd-ripple-container"
/>
</button>
</div>
<div
aria-live="polite"
class="rmd-form-message rmd-form-message--outline"
id="field-id-message"
/>
</div>
</div>
`;
Loading

0 comments on commit f6d84f2

Please sign in to comment.