Skip to content

Commit

Permalink
feat(OTP): introduce OTP component
Browse files Browse the repository at this point in the history
Also added OTP demo page to doc site.
  • Loading branch information
ivawzh committed Sep 27, 2023
1 parent d7eb002 commit 9b45133
Show file tree
Hide file tree
Showing 15 changed files with 30,518 additions and 20,161 deletions.
16,188 changes: 10,281 additions & 5,907 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"build-basis:cjs": "cross-env NODE_ENV=production BABEL_ENV=cjs babel src -d dist --ignore \"src/**/*.test.js\"",
"build-basis:esm": "cross-env NODE_ENV=production BABEL_ENV=esm babel src -d dist/esm --ignore \"src/**/*.test.js\"",
"build-basis": "npm run build-basis:cjs && npm run build-basis:esm",
"build-basis:watch": "nodemon --watch . --ignore ./dist --exec \"npm run build-basis:cjs\"",
"prebuild": "rimraf public",
"build": "cd website && npm run build && mv public .. && cd ..",
"prepublishOnly": "npm run build-basis",
Expand All @@ -29,7 +30,8 @@
"mem": "6.1.1",
"nanoid": "3.1.22",
"polished": "3.7.1",
"react-keyed-flatten-children": "1.3.0"
"react-keyed-flatten-children": "1.3.0",
"react-otp-input": "^3.0.4"
},
"peerDependencies": {
"@emotion/core": "^10.0.28",
Expand Down Expand Up @@ -67,6 +69,7 @@
"inquirer": "^8.1.5",
"jest": "26.6.3",
"mq-polyfill": "1.1.8",
"nodemon": "^3.0.1",
"prettier": "2.2.1",
"prop-types": "15.7.2",
"react": "16.14.0",
Expand Down
12 changes: 6 additions & 6 deletions src/components/Form.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const DEFAULT_PROPS = {
};

function getParentFieldName(target) {
return target.dataset.parentName ?? target.name;
return target?.dataset?.parentName ?? target.name;
}

Form.DEFAULT_PROPS = DEFAULT_PROPS;
Expand Down Expand Up @@ -73,7 +73,7 @@ function Form(_props) {
lastMouseDownInputElement.current = null;
}

/*
/*
We use setTimeout in order to differentiate between onBlur to another field within
the same parent (e.g. DatePicker) and onBlur out of the parent.
*/
Expand Down Expand Up @@ -146,12 +146,12 @@ function Form(_props) {
const getFieldErrors = useCallback((values, name) => {
const value = getPath(values, name);
/*
Note:
`getFieldErrors` is called by `useEffect` below when `namesToValidate` change,
and we set `namesToValidate` inside `setTimeout` in `onBlur`. This means that
Note:
`getFieldErrors` is called by `useEffect` below when `namesToValidate` change,
and we set `namesToValidate` inside `setTimeout` in `onBlur`. This means that
`getFieldErrors` will be called with a little delay.
This opens the door for `unregisterField` being called BEFORE `getFieldErrors` is called.
Think about an input field being focused and then the user clicks on a Next button which
Think about an input field being focused and then the user clicks on a Next button which
unmounts the current form and mounts the next form page.
In this case, `getFieldErrors` will be called with a `name` that doesn't exist in `fields.current`
anymore since `unregisterField` deleted it.
Expand Down
134 changes: 123 additions & 11 deletions src/components/OtpInput.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,34 @@
import React, { useState, useCallback } from "react";
import React, { useState, useCallback, useMemo } from "react";
import PropTypes from "prop-types";
import { nanoid } from "nanoid";
import useField from "../hooks/internal/useField";
import { mergeProps } from "../utils/component";
import Field from "./internal/Field";
import OTPInput from "react-otp-input";
import useTheme from "../hooks/useTheme";
import useBackground from "../hooks/useBackground";
import useResponsivePropsCSS from "../hooks/useResponsivePropsCSS";

const DEFAULT_PROPS = {
numInputs: 6,
disabled: false,
optional: false,
color: "grey.t05",
shouldAutoFocus: true,
validate: (value, { isEmpty }) => {
if (isEmpty(value)) {
return "Required";
}

if (value.length !== 6) {
return "Must be 6 digits";
}

return null;
},
};

function OtpInput(props) {
export default function OtpInput(props) {
const mergedProps = mergeProps(
props,
DEFAULT_PROPS,
Expand All @@ -22,7 +37,8 @@ function OtpInput(props) {
numInputs: (numInputs) => typeof numInputs === "number",
disabled: (disabled) => typeof disabled === "boolean",
optional: (optional) => typeof optional === "boolean",
shouldAutoFocus: (shouldAutoFocus) => typeof shouldAutoFocus === "boolean",
shouldAutoFocus: (shouldAutoFocus) =>
typeof shouldAutoFocus === "boolean",
value: (value) => typeof value === "string",
}
);
Expand All @@ -36,24 +52,39 @@ function OtpInput(props) {
label,
helpText,
testId,
validate,
validateData,
} = mergedProps;
const isEmpty = useCallback((value) => value.trim() === "", []);
const validationData = useMemo(
() => ({
isEmpty,
...(validateData && { data: validateData }),
}),
[isEmpty, validateData]
);

const [otpInputId] = useState(() => `otp-input-${nanoid()}`);
const [auxId] = useState(() => `otp-input-aux-${nanoid()}`);

const {
value,
errors,
hasErrors,
onFocus,
onBlur,
onChange: fieldOnChange,
} = useField("OtpInput", {
name,
disabled: disabled,
disabled,
optional,
validate,
data: validationData,
});

const onChange = useCallback(
(otpValue) => {
fieldOnChange({ target: { value: otpValue, name }});
fieldOnChange({ target: { value: otpValue, name } });
onChangeProp && onChangeProp(otpValue);
},
[fieldOnChange, onChangeProp, name]
Expand All @@ -75,16 +106,27 @@ function OtpInput(props) {
value={value}
onChange={onChange}
numInputs={numInputs}
containerStyle={{gap: "4px"}}
renderInput={(inputProps, _index) => {
return <input
{...inputProps}
/>}}
containerStyle={{ gap: "4px" }}
renderInput={(inputProps, index) => {
return (
<InternalInput
name={name}
inputProps={inputProps}
hasErrors={hasErrors}
disabled={disabled}
onFocus={onFocus}
onBlur={onBlur}
index={index}
/>
);
}}
/>
</Field>
);
}

OtpInput.DEFAULT_PROPS = DEFAULT_PROPS;

OtpInput.propTypes = {
name: PropTypes.string.isRequired,
color: PropTypes.string,
Expand All @@ -93,9 +135,79 @@ OtpInput.propTypes = {
shouldAutoFocus: PropTypes.bool,
value: PropTypes.string,
onChange: PropTypes.func,
validate: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
validateData: PropTypes.any,
label: PropTypes.string.isRequired,
helpText: PropTypes.node,
testId: PropTypes.string,
};

export default OtpInput;
function InternalInput(props) {
const {
hasErrors,
onFocus,
onBlur,
index,
name,
disabled,
inputProps: { style, ...otpInputProps },
} = props;
const theme = useTheme();
const { inputColorMap } = useBackground();
const inputCSS = useResponsivePropsCSS(props, DEFAULT_PROPS, {
color: (propsAtBreakpoint, theme, bp) => {
const color = props.color ?? inputColorMap[bp];

return theme.otpInput.getCSS({
targetElement: "input",
color,
});
},
});

const internalOnFocus = useCallback(
(event) => {
otpInputProps.onFocus(event);
onFocus(event);
},
[otpInputProps, onFocus]
);

const internalOnBlur = useCallback(
(event) => {
otpInputProps.onBlur(event);
onBlur(event);
},
[otpInputProps, onBlur]
);

return (
<div
css={theme.input.getCSS({
targetElement: "inputContainer",
})}
>
<input
aria-invalid={hasErrors ? "true" : null}
{...otpInputProps}
css={inputCSS}
disabled={disabled}
name={name}
onFocus={internalOnFocus}
onBlur={internalOnBlur}
index={index}
/>
</div>
);
}

InternalInput.propTypes = {
name: PropTypes.string.isRequired,
disabled: PropTypes.bool,
color: PropTypes.string,
hasErrors: PropTypes.bool.isRequired,
onFocus: PropTypes.func.isRequired,
onBlur: PropTypes.func.isRequired,
index: PropTypes.number.isRequired,
inputProps: PropTypes.object.isRequired,
};
56 changes: 56 additions & 0 deletions src/components/OtpInput.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React from "react";
import { render, screen, waitFor, fireEvent } from "../utils/test";
import "@testing-library/jest-dom/extend-expect";
import Form from "./Form";
import OtpInput from "./OtpInput";

function FormWithOtpInput(props) {
const initialValues = {
otp: "",
};

return (
<Form initialValues={initialValues}>
<OtpInput name="otp" {...props} />
</Form>
);
}

describe("OtpInput", () => {
it("renders correctly", () => {
render(<FormWithOtpInput label="Enter OTP" testId="otp" />);
expect(screen.getByTestId("otp")).toBeInTheDocument();
});

it("renders default number of inputs", () => {
render(<FormWithOtpInput label="Enter OTP" />);
const inputs = screen.getAllByRole("textbox");
expect(inputs.length).toBe(6);
});

it("becomes disabled when the disabled prop is true", () => {
render(<FormWithOtpInput label="Enter OTP" disabled />);
const inputs = screen.getAllByRole("textbox");
inputs.forEach((input) => {
expect(input).toBeDisabled();
});
});

it("calls onChange when value changes", () => {
const onChange = jest.fn();
render(<FormWithOtpInput label="Enter OTP" onChange={onChange} />);
const firstInput = screen.getAllByRole("textbox")[0];
fireEvent.change(firstInput, { target: { value: "1" } });
expect(onChange).toHaveBeenCalledWith("1");
});

it("displays an error message for invalid input", async () => {
render(<FormWithOtpInput label="Enter OTP" />);
const firstInput = screen.getAllByRole("textbox")[0];
fireEvent.change(firstInput, { target: { value: "1" } });
fireEvent.blur(firstInput);
await waitFor(() => {
expect(screen.getByText("Must be 6 digits")).toBeInTheDocument();
});
});
});
1 change: 1 addition & 0 deletions src/components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ export { default as Text } from "./Text";
export { default as Textarea } from "./Textarea";
export { default as TimeSpan } from "./TimeSpan";
export { default as VisuallyHidden } from "./VisuallyHidden";
export { default as OtpInput } from "./OtpInput";
2 changes: 2 additions & 0 deletions src/themes/default/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import checkbox from "./checkbox";
import dropdown from "./dropdown";
import field from "./field";
import input from "./input";
import otpInput from "./otpInput";
import link from "./link";
import list from "./list";
import radioGroup from "./radioGroup";
Expand Down Expand Up @@ -266,6 +267,7 @@ export default {
dropdown: dropdown(theme, helpers),
field: field(theme, helpers),
input: input(theme, helpers),
otpInput: otpInput(theme, helpers),
link: link(theme, helpers),
list: list(theme, helpers),
radioGroup: radioGroup(theme, helpers),
Expand Down
48 changes: 48 additions & 0 deletions src/themes/default/otpInput.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
export default (theme, { getColor }) => {
return {
getCSS: ({ targetElement, color }) => {
switch (targetElement) {
case "inputContainer": {
return {
position: "relative",
fontSize: theme.fontSizes[1],
fontWeight: theme.fontWeights.light,
lineHeight: theme.lineHeights[2],
fontFamily: theme.fonts.body,
color: theme.colors.black,
};
}

case "input": {
const focusStyle = {
outline: 0,
borderRadius: theme.radii[0],
boxShadow: theme.shadows.focus,
};

return {
textAlign: "center",
boxSizing: "border-box",
width: "100%",
height: "48px",
border: 0,
margin: 0,
paddingTop: 0,
paddingBottom: 0,
fontSize: "inherit",
fontWeight: "inherit",
lineHeight: "inherit",
fontFamily: "inherit",
color: "inherit",
backgroundColor: getColor(color),
":focus": focusStyle,
};
}

default: {
return null;
}
}
},
};
};
Loading

0 comments on commit 9b45133

Please sign in to comment.