diff --git a/.changeset/old-ghosts-yell.md b/.changeset/old-ghosts-yell.md new file mode 100644 index 000000000..b2c4a0644 --- /dev/null +++ b/.changeset/old-ghosts-yell.md @@ -0,0 +1,9 @@ +--- +"@khanacademy/wonder-blocks-form": minor +--- + +- `TextField` + - Add `instantValidation` prop + - No longer calls `validate` prop if the field is disabled during initialization and on change +- `TextArea` + - Validate the value during initialization if the field is not disabled diff --git a/__docs__/wonder-blocks-form/form-utilities.ts b/__docs__/wonder-blocks-form/form-utilities.ts new file mode 100644 index 000000000..d3c7a8709 --- /dev/null +++ b/__docs__/wonder-blocks-form/form-utilities.ts @@ -0,0 +1,23 @@ +/** + * Checks if a value is a valid email and returns an error message if it is invalid. + * @param value the email to validate + * @returns An error message if there is a validation error + */ +export const validateEmail = (value: string) => { + const emailRegex = /^[^@\s]+@[^@\s.]+\.[^@.\s]+$/; + if (!emailRegex.test(value)) { + return "Please enter a valid email"; + } +}; + +/** + * Checks if a value is a valid phone number and returns an error message if it is invalid. + * @param value the phone number to validate + * @returns An error message if there is a validation error + */ +export const validatePhoneNumber = (value: string) => { + const telRegex = /^\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$/; + if (!telRegex.test(value)) { + return "Invalid US telephone number"; + } +}; diff --git a/__docs__/wonder-blocks-form/text-area.stories.tsx b/__docs__/wonder-blocks-form/text-area.stories.tsx index 65e0e0e3b..1f8e2251c 100644 --- a/__docs__/wonder-blocks-form/text-area.stories.tsx +++ b/__docs__/wonder-blocks-form/text-area.stories.tsx @@ -13,6 +13,7 @@ import {Strut} from "@khanacademy/wonder-blocks-layout"; import {PropsFor, View} from "@khanacademy/wonder-blocks-core"; import TextAreaArgTypes from "./text-area.argtypes"; +import {validateEmail} from "./form-utilities"; /** * A TextArea is an element used to accept text from the user. @@ -200,12 +201,7 @@ export const Error: StoryComponentType = { export const ErrorFromValidation: StoryComponentType = { args: { value: "khan", - validate(value: string) { - const emailRegex = /^[^@\s]+@[^@\s.]+\.[^@.\s]+$/; - if (!emailRegex.test(value)) { - return "Please enter a valid email"; - } - }, + validate: validateEmail, }, render: ControlledTextArea, parameters: { @@ -255,12 +251,7 @@ export const ErrorFromPropAndValidation = (args: PropsFor) => { {...args} value={value} onChange={handleChange} - validate={(value: string) => { - const emailRegex = /^[^@\s]+@[^@\s.]+\.[^@.\s]+$/; - if (!emailRegex.test(value)) { - return "Please enter a valid email"; - } - }} + validate={validateEmail} onValidate={setValidationErrorMessage} error={!!errorMessage} /> @@ -315,12 +306,7 @@ ErrorFromPropAndValidation.parameters = { */ export const InstantValidation: StoryComponentType = { args: { - validate(value: string) { - const emailRegex = /^[^@\s]+@[^@\s.]+\.[^@.\s]+$/; - if (!emailRegex.test(value)) { - return "Please enter a valid email"; - } - }, + validate: validateEmail, }, render: (args) => { return ( diff --git a/__docs__/wonder-blocks-form/text-field.argtypes.ts b/__docs__/wonder-blocks-form/text-field.argtypes.ts index 2e675c019..d9bf9fa76 100644 --- a/__docs__/wonder-blocks-form/text-field.argtypes.ts +++ b/__docs__/wonder-blocks-form/text-field.argtypes.ts @@ -187,6 +187,19 @@ export default { }, }, + instantValidation: { + description: + "If true, TextField is validated as the user types (onChange). If false, it is validated when the user's focus moves out of the field (onBlur). It is preferred that instantValidation is set to `false`, however, it defaults to `true` for backwards compatibility with existing implementations.", + table: { + type: { + summary: "boolean", + }, + }, + control: { + type: "boolean", + }, + }, + /** * Number-specific props */ diff --git a/__docs__/wonder-blocks-form/text-field.stories.tsx b/__docs__/wonder-blocks-form/text-field.stories.tsx index b78d562b0..89ca9e2e6 100644 --- a/__docs__/wonder-blocks-form/text-field.stories.tsx +++ b/__docs__/wonder-blocks-form/text-field.stories.tsx @@ -18,6 +18,7 @@ import packageConfig from "../../packages/wonder-blocks-form/package.json"; import ComponentInfo from "../../.storybook/components/component-info"; import TextFieldArgTypes from "./text-field.argtypes"; +import {validateEmail, validatePhoneNumber} from "./form-utilities"; /** * A TextField is an element used to accept a single line of text from the user. @@ -269,13 +270,6 @@ export const Email: StoryComponentType = () => { setValue(newValue); }; - const validate = (value: string) => { - const emailRegex = /^[^@\s]+@[^@\s.]+\.[^@.\s]+$/; - if (!emailRegex.test(value)) { - return "Please enter a valid email"; - } - }; - const handleValidate = (errorMessage?: string | null) => { setErrorMessage(errorMessage); }; @@ -301,7 +295,7 @@ export const Email: StoryComponentType = () => { type="email" value={value} placeholder="Email" - validate={validate} + validate={validateEmail} onValidate={handleValidate} onChange={handleChange} onKeyDown={handleKeyDown} @@ -338,13 +332,6 @@ export const Telephone: StoryComponentType = () => { setValue(newValue); }; - const validate = (value: string) => { - const telRegex = /^\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$/; - if (!telRegex.test(value)) { - return "Invalid US telephone number"; - } - }; - const handleValidate = (errorMessage?: string | null) => { setErrorMessage(errorMessage); }; @@ -370,7 +357,7 @@ export const Telephone: StoryComponentType = () => { type="tel" value={value} placeholder="Telephone" - validate={validate} + validate={validatePhoneNumber} onValidate={handleValidate} onChange={handleChange} onKeyDown={handleKeyDown} @@ -398,6 +385,32 @@ Telephone.parameters = { }, }; +const ControlledTextField = (args: PropsFor) => { + const [value, setValue] = React.useState(args.value || ""); + const [error, setError] = React.useState(null); + + const handleChange = (newValue: string) => { + setValue(newValue); + }; + + return ( + + + + {(error || args.error) && ( + + {error || "Error from error prop"} + + )} + + ); +}; + function ErrorRender(args: PropsFor) { const [value, setValue] = React.useState("khan"); const [errorMessage, setErrorMessage] = React.useState(); @@ -406,13 +419,6 @@ function ErrorRender(args: PropsFor) { setValue(newValue); }; - const validate = (value: string) => { - const emailRegex = /^[^@\s]+@[^@\s.]+\.[^@.\s]+$/; - if (!emailRegex.test(value)) { - return "Please enter a valid email"; - } - }; - const handleValidate = (errorMessage?: string | null) => { setErrorMessage(errorMessage); }; @@ -429,7 +435,7 @@ function ErrorRender(args: PropsFor) { id="tf-7" type="email" placeholder="Email" - validate={validate} + validate={validateEmail} onValidate={handleValidate} onKeyDown={handleKeyDown} {...args} @@ -534,12 +540,7 @@ export const ErrorFromPropAndValidation = ( {...args} value={value} onChange={handleChange} - validate={(value: string) => { - const emailRegex = /^[^@\s]+@[^@\s.]+\.[^@.\s]+$/; - if (!emailRegex.test(value)) { - return "Please enter a valid email"; - } - }} + validate={validateEmail} onValidate={setValidationErrorMessage} error={!!errorMessage} /> @@ -574,6 +575,94 @@ ErrorFromPropAndValidation.parameters = { }, }; +/** + * The `instantValidation` prop controls when validation is triggered. Validation + * is triggered if the `validate` or `required` props are set. + * + * It is preferred to set `instantValidation` to `false` so that the user isn't + * shown an error until they are done with a field. Note: if `instantValidation` + * is not explicitly set, it defaults to `true` since this is the current + * behaviour of existing usage. Validation on blur needs to be opted in. + * + * Validation is triggered: + * - On mount if the `value` prop is not empty + * - If `instantValidation` is `true`, validation occurs `onChange` (default) + * - If `instantValidation` is `false`, validation occurs `onBlur` + * + * When `required` is set to `true`: + * - If `instantValidation` is `true`, the required error message is shown after + * a value is cleared + * - If `instantValidation` is `false`, the required error message is shown + * whenever the user tabs away from the required field + */ +export const InstantValidation: StoryComponentType = { + args: { + validate: validateEmail, + }, + render: (args) => { + return ( + + + Validation on mount if there is a value + + + + Error shown immediately (instantValidation: true, required: + false) + + + + Error shown onBlur (instantValidation: false, required: + false) + + + + + Error shown immediately after clearing the value + (instantValidation: true, required: true) + + + + Error shown on blur if it is empty (instantValidation: + false, required: true) + + + + ); + }, + parameters: { + chromatic: { + // Disabling because this doesn't test anything visual. + disableSnapshot: true, + }, + }, +}; + export const Light: StoryComponentType = () => { const [value, setValue] = React.useState("khan@khanacademy.org"); const [errorMessage, setErrorMessage] = React.useState(); @@ -583,13 +672,6 @@ export const Light: StoryComponentType = () => { setValue(newValue); }; - const validate = (value: string) => { - const emailRegex = /^[^@\s]+@[^@\s.]+\.[^@.\s]+$/; - if (!emailRegex.test(value)) { - return "Please enter a valid email"; - } - }; - const handleValidate = (errorMessage?: string | null) => { setErrorMessage(errorMessage); }; @@ -616,7 +698,7 @@ export const Light: StoryComponentType = () => { value={value} placeholder="Email" light={true} - validate={validate} + validate={validateEmail} onValidate={handleValidate} onChange={handleChange} onKeyDown={handleKeyDown} @@ -655,13 +737,6 @@ export const ErrorLight: StoryComponentType = () => { setValue(newValue); }; - const validate = (value: string) => { - const emailRegex = /^[^@\s]+@[^@\s.]+\.[^@.\s]+$/; - if (!emailRegex.test(value)) { - return "Please enter a valid email"; - } - }; - const handleValidate = (errorMessage?: string | null) => { setErrorMessage(errorMessage); }; @@ -688,7 +763,7 @@ export const ErrorLight: StoryComponentType = () => { value={value} placeholder="Email" light={true} - validate={validate} + validate={validateEmail} onValidate={handleValidate} onChange={handleChange} onKeyDown={handleKeyDown} diff --git a/packages/wonder-blocks-form/src/components/__tests__/text-area.test.tsx b/packages/wonder-blocks-form/src/components/__tests__/text-area.test.tsx index 880643224..cfb7c6e94 100644 --- a/packages/wonder-blocks-form/src/components/__tests__/text-area.test.tsx +++ b/packages/wonder-blocks-form/src/components/__tests__/text-area.test.tsx @@ -751,6 +751,8 @@ describe("TextArea", () => { it("should set the aria-details attribute when provided", async () => { // Arrange const ariaDetails = "details-id"; + + // Act render(