Skip to content

Commit

Permalink
Refactor form validation (#17)
Browse files Browse the repository at this point in the history
  • Loading branch information
moroshko authored Feb 20, 2020
1 parent 5f35314 commit 77ff6ab
Show file tree
Hide file tree
Showing 75 changed files with 3,147 additions and 2,233 deletions.
199 changes: 64 additions & 135 deletions src/components/Checkbox.js
Original file line number Diff line number Diff line change
@@ -1,109 +1,83 @@
import React, { useState } from "react";
import React, { useState, useEffect, useCallback } from "react";
import PropTypes from "prop-types";
import nanoid from "nanoid";
import useTheme from "../hooks/useTheme";
import useBackground from "../hooks/useBackground";
import useValidation from "../hooks/useValidation";
import useForm from "../hooks/internal/useForm";
import { mergeProps } from "../utils/component";
import Field from "./internal/Field";
import VisuallyHidden from "./VisuallyHidden";
import InternalCheckbox from "./internal/InternalCheckbox";

const COLORS = ["grey.t05", "white"];
const { COLORS } = InternalCheckbox;

const DEFAULT_PROPS = {
color: "grey.t05",
optional: false,
color: InternalCheckbox.DEFAULT_PROPS.color,
disabled: false,
validation: [
{
condition: ({ optional }) => !optional,
validator: (value, { isTouched }) => {
if (!isTouched) {
return null;
}

if (value === false) {
return "Must be checked";
}

return null;
}
__internal__keyboardFocus: false,
optional: false,
validate: (value, { isEmpty }) => {
if (isEmpty(value)) {
return "Must be checked";
}
]

return null;
}
};

Checkbox.COLORS = COLORS;
Checkbox.DEFAULT_PROPS = DEFAULT_PROPS;

function CheckboxIcon({ color, isChecked }) {
const theme = useTheme();

return (
<svg
css={theme.checkboxIcon}
viewBox="0 0 100 100"
focusable="false"
aria-hidden="true"
>
<rect
css={theme[`checkboxIcon.${color}`]}
width="100"
height="100"
rx="16"
/>
{isChecked && (
<path
css={theme.checkboxIconMark}
d="M21 51 l22 18 l35 -39"
fill="transparent"
strokeWidth="14"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
</svg>
);
}

CheckboxIcon.propTypes = {
color: PropTypes.oneOf(["white", "secondary.lightBlue.t30"]).isRequired,
isChecked: PropTypes.bool.isRequired
};

function Checkbox(props) {
const { inputColor } = useBackground();
const inheritedProps = {
color: inputColor
};
const mergedProps = mergeProps(props, DEFAULT_PROPS, inheritedProps, {
color: color => COLORS.includes(color),
optional: optional => typeof optional === "boolean",
disabled: disabled => typeof disabled === "boolean"
disabled: disabled => typeof disabled === "boolean",
optional: optional => typeof optional === "boolean"
});
const {
name,
label,
color,
optional,
helpText,
disabled,
data,
onChange,
optional,
validate,
children,
testId,
__internal__keyboardFocus
} = mergedProps;
const theme = useTheme();
const [labelId] = useState(() => `radio-group-label-${nanoid()}`);
const [inputId] = useState(() => `checkbox-${nanoid()}`);
const [auxId] = useState(() => `checkbox-aux-${nanoid()}`);
const [isTouched, setIsTouched] = useState(false);
const { value: isChecked, errors } = data;
const validate = useValidation({
props: mergedProps,
extraData: {
isTouched
}
});
const {
state,
onFocus,
onBlur,
onChange,
onMouseDown,
registerField,
unregisterField
} = useForm();
const value = state.values[name];
const errors = state.errors[name];
const hasErrors = Array.isArray(errors) && errors.length > 0;
const isEmpty = useCallback(value => value === false, []);

useEffect(() => {
registerField(name, {
optional,
validate,
data: {
isEmpty
}
});

return () => {
unregisterField(name);
};
}, [name, optional, validate, isEmpty, registerField, unregisterField]);

return (
<Field
Expand All @@ -116,80 +90,35 @@ function Checkbox(props) {
errors={errors}
testId={testId}
>
<div
css={theme.checkboxLabelContainer}
role="checkbox"
aria-invalid={errors ? "true" : null}
aria-checked={isChecked}
aria-labelledby={label ? labelId : null}
aria-describedby={helpText || errors ? auxId : null}
<InternalCheckbox
name={name}
inputId={inputId}
color={color}
disabled={disabled}
isValid={!hasErrors}
labelledBy={label ? labelId : null}
describedBy={helpText || hasErrors ? auxId : null}
onFocus={onFocus}
onBlur={onBlur}
onMouseDown={onMouseDown}
value={value}
onChange={onChange}
__internal__keyboardFocus={__internal__keyboardFocus}
>
<VisuallyHidden>
<input
css={{
":focus-visible + label": theme["checkboxLabel.focus-visible"],
":checked + label": theme["checkboxLabel.checked"]
}}
type="checkbox"
id={inputId}
checked={isChecked}
disabled={disabled}
onFocus={() => {
setIsTouched(true);
}}
onBlur={validate}
onChange={e => {
onChange({
...data,
value: e.target.checked
});
}}
/>
</VisuallyHidden>
<label
css={{
...theme.checkboxLabel,
...theme[`checkboxLabel.${color}`],
...(__internal__keyboardFocus &&
theme["checkboxLabel.focus-visible"])
}}
htmlFor={inputId}
>
<CheckboxIcon
color={
color === "grey.t05" || isChecked
? "white"
: "secondary.lightBlue.t30"
}
isChecked={isChecked}
/>
<span /* This span is needed so that we could mix text and <Link>. Without it, the white space between them would be ignored. */
>
{children}
</span>
</label>
</div>
{children}
</InternalCheckbox>
</Field>
);
}

Checkbox.propTypes = {
name: PropTypes.string.isRequired,
label: PropTypes.string,
color: PropTypes.oneOf(COLORS),
optional: PropTypes.bool,
helpText: PropTypes.string,
disabled: PropTypes.bool,
validation: PropTypes.arrayOf(
PropTypes.shape({
condition: PropTypes.func,
validator: PropTypes.func.isRequired
})
),
data: PropTypes.shape({
value: PropTypes.bool.isRequired,
errors: PropTypes.arrayOf(PropTypes.node)
}).isRequired,
onChange: PropTypes.func.isRequired,
optional: PropTypes.bool,
validate: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
children: PropTypes.node.isRequired,
testId: PropTypes.string,
__internal__keyboardFocus: PropTypes.bool
Expand Down
37 changes: 26 additions & 11 deletions src/components/Checkbox.test.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
import React, { useState } from "react";
import React from "react";
import { render } from "../utils/test";
import "@testing-library/jest-dom/extend-expect";
import Form from "./Form";
import Checkbox from "./Checkbox";
import Container from "./Container";

function App(props) {
const [data, onChange] = useState({
value: false
});
function FormWithCheckbox(props) {
const initialValues = {
terms: false
};

return <Checkbox data={data} onChange={onChange} {...props} />;
return (
<Form initialValues={initialValues}>
<Checkbox name="terms" {...props} />
</Form>
);
}

describe("Checkbox", () => {
it("renders label that is connected to checkbox", () => {
const { container, getByText } = render(
<App label="Accept terms and conditions">I agree</App>
<FormWithCheckbox label="Accept terms and conditions">
I agree
</FormWithCheckbox>
);
const label = getByText("Accept terms and conditions");
const checkboxContainer = container.querySelector("[aria-checked]");
Expand Down Expand Up @@ -51,7 +58,9 @@ describe("Checkbox", () => {
it("inside dark container", () => {
const { container } = render(
<Container bg="primary.blue.t100">
<App label="Accept terms and conditions">I agree</App>
<FormWithCheckbox label="Accept terms and conditions">
I agree
</FormWithCheckbox>
</Container>
);
const checkboxContainer = container.querySelector("[aria-checked]");
Expand All @@ -64,11 +73,17 @@ describe("Checkbox", () => {

it("with testId", () => {
const { container } = render(
<App label="Accept terms and conditions" testId="my-checkbox">
<FormWithCheckbox
label="Accept terms and conditions"
testId="my-checkbox"
>
I agree
</App>
</FormWithCheckbox>
);

expect(container.firstChild).toHaveAttribute("data-testid", "my-checkbox");
expect(container.querySelector("form").firstChild).toHaveAttribute(
"data-testid",
"my-checkbox"
);
});
});
Loading

1 comment on commit 77ff6ab

@vercel
Copy link

@vercel vercel bot commented on 77ff6ab Feb 20, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.