diff --git a/.changeset/chilly-mirrors-add.md b/.changeset/chilly-mirrors-add.md
new file mode 100644
index 000000000..4d82d7b9d
--- /dev/null
+++ b/.changeset/chilly-mirrors-add.md
@@ -0,0 +1,5 @@
+---
+"@khanacademy/wonder-blocks-form": patch
+---
+
+TextField and TextArea: Set `aria-required` if it is required
diff --git a/.changeset/metal-maps-move.md b/.changeset/metal-maps-move.md
new file mode 100644
index 000000000..f4a0999de
--- /dev/null
+++ b/.changeset/metal-maps-move.md
@@ -0,0 +1,5 @@
+---
+"@khanacademy/wonder-blocks-form": patch
+---
+
+TextField and TextArea validation: Always clear error message onChange if instantValidation=false so externally set error state can still be cleared
diff --git a/.changeset/slow-otters-crash.md b/.changeset/slow-otters-crash.md
new file mode 100644
index 000000000..0888206d7
--- /dev/null
+++ b/.changeset/slow-otters-crash.md
@@ -0,0 +1,5 @@
+---
+"@khanacademy/wonder-blocks-labeled-field": patch
+---
+
+Set required, error and light props for LabeledField and field component if it is set on either LabeledField or field component
diff --git a/.changeset/smart-grapes-serve.md b/.changeset/smart-grapes-serve.md
new file mode 100644
index 000000000..5835a09e5
--- /dev/null
+++ b/.changeset/smart-grapes-serve.md
@@ -0,0 +1,5 @@
+---
+"@khanacademy/wonder-blocks-labeled-field": patch
+---
+
+Use `errorMessage` prop instead of `error` prop for consistency (`error` prop is used for boolean props in form field components).
diff --git a/.changeset/spicy-rivers-marry.md b/.changeset/spicy-rivers-marry.md
new file mode 100644
index 000000000..be94ec3c4
--- /dev/null
+++ b/.changeset/spicy-rivers-marry.md
@@ -0,0 +1,5 @@
+---
+"@khanacademy/wonder-blocks-labeled-field": patch
+---
+
+LabeledField: Let `required` prop be a boolean or string so it can be passed down to the field prop
diff --git a/__docs__/wonder-blocks-form/_overview_.mdx b/__docs__/wonder-blocks-form/_overview_.mdx
index 78c7e733d..cdece2c84 100644
--- a/__docs__/wonder-blocks-form/_overview_.mdx
+++ b/__docs__/wonder-blocks-form/_overview_.mdx
@@ -1,4 +1,6 @@
-import {Meta} from "@storybook/blocks";
+import {Meta, Story, Canvas} from "@storybook/blocks";
+import * as AccessibilityStories from './accessibility.stories';
+import * as LabeledFieldStories from '../wonder-blocks-labeled-field/labeled-field.stories';
` and
+`` elements instead of a `` since the label is for a group of
+related controls. See [Grouping Controls](https://www.w3.org/WAI/tutorials/forms/grouping/)
+for more details!
+- For custom implementations of field labels:
+ - Make sure the id of the form field element is unique to the page
+ - When using Wonder Blocks typography components for the form field label,
+ set the `tag` prop to `label` to change the underlying element to render and
+ use the `htmlFor` prop. Here is an example of a form field label using Wonder
+ Blocks components `LabelMedium` and `TextArea`:
+
+
+
+### Error Validation
+
+- For fields like `TextField`, `TextArea`, and `SearchField` prefer setting
+`instantValidation=false`. This makes it so validation occurs on blur after a
+user is done interacting with a field.
+- Avoid disabling form submission buttons. There could be exceptions if the
+button is for one field.
+- If there are errors after a form is submitted, programatically move the user's
+focus to the first field with an error.
+
+Here is an example of validation behaviour in a form. It validates when a
+user is done filling out a field and also shows a validation error once
+the form is submitted (this simulates a backend validation error). When the form
+is submitted, the focus is also moved to the first field with an error.
+
+
diff --git a/__docs__/wonder-blocks-form/accessibility.mdx b/__docs__/wonder-blocks-form/accessibility.mdx
deleted file mode 100644
index 21a876f9a..000000000
--- a/__docs__/wonder-blocks-form/accessibility.mdx
+++ /dev/null
@@ -1,34 +0,0 @@
-import {Meta, Story, Canvas} from "@storybook/blocks";
-import * as AccessibilityStories from './accessibility.stories';
-
-
-
-# Form Accessibility
-
-## Form Field Labels
-
-Form fields should have an associated `` element that has the `htmlFor`
-prop set to the `id` of the form field element. This is helpful for
-screenreaders so that it can communicate the relationship between the label and
-the form field.
-
-### Guidelines
-- Make sure the id of the form field element is unique to the page
-- Consider using the `LabeledTextField` component if you are using `TextField`.
-This will automatically wire up the label and text input. (Note: we will have
-a more generic `LabelField` component later on to provide this functionality
-for other form fields.)
-- If you are using the `CheckboxGroup` or `RadioGroup` components, the accessible
-label for the field is already built-in when the `label` prop is used. This
-uses `` and `` elements instead of a `` since the label
-is for a group of related controls. See
-[Grouping Controls](https://www.w3.org/WAI/tutorials/forms/grouping/)
-for more details!
-- When using Wonder Blocks typography components for form labels, the `tag`
-prop can be set to `label` to change the underlying element to render.
-
-
-Here is an example of a form field label using Wonder Blocks components
-`LabelMedium` and `TextArea`:
-
-
diff --git a/__docs__/wonder-blocks-form/accessibility.stories.tsx b/__docs__/wonder-blocks-form/accessibility.stories.tsx
index 447719853..3bd9b57e5 100644
--- a/__docs__/wonder-blocks-form/accessibility.stories.tsx
+++ b/__docs__/wonder-blocks-form/accessibility.stories.tsx
@@ -6,7 +6,7 @@ import {TextArea} from "@khanacademy/wonder-blocks-form";
import {spacing} from "@khanacademy/wonder-blocks-tokens";
export default {
- title: "Packages / Form / Accessibility",
+ title: "Packages / Form / Overview", // Named the same as overiew docs to hide it from the sidebar
parameters: {
previewTabs: {
canvas: {
diff --git a/__docs__/wonder-blocks-form/labeled-text-field.stories.tsx b/__docs__/wonder-blocks-form/labeled-text-field.stories.tsx
index 06c387218..f3e05e61b 100644
--- a/__docs__/wonder-blocks-form/labeled-text-field.stories.tsx
+++ b/__docs__/wonder-blocks-form/labeled-text-field.stories.tsx
@@ -13,13 +13,18 @@ import packageConfig from "../../packages/wonder-blocks-form/package.json";
import ComponentInfo from "../../.storybook/components/component-info";
import LabeledTextFieldArgTypes from "./labeled-text-field.argtypes";
+import {LabeledField} from "@khanacademy/wonder-blocks-labeled-field";
+import TextField from "../../packages/wonder-blocks-form/src/components/text-field";
/**
+ * ** DEPRECATED: Please use LabeledField with TextField instead. See [Migration
+ * story](#migration%20to%20labeled%20field) for more details. **
+ *
* A LabeledTextField is an element used to accept a single line of text from
* the user paired with a label, description, and error field elements.
*/
export default {
- title: "Packages / Form / LabeledTextField",
+ title: "Packages / Form / LabeledTextField (Deprecated)",
component: LabeledTextField,
parameters: {
componentSubtitle: (
@@ -56,6 +61,73 @@ export const Default: StoryComponentType = {
},
};
+/**
+ * Please use the **LabeledField** component with the **TextField** component
+ * instead of the `LabeledTextField` component.
+ *
+ * LabeledField is more flexible since it is decoupled from specific field
+ * components. It also allows use of everything supported by TextField since it
+ * is used directly.
+ *
+ * Note: Validation is now handled by the specific field component and an error
+ * message is passed into LabeledField. For TextField validation, it is preferred
+ * that validation occurs on blur once a user is done interacting with a field.
+ * This can be done using `instantValidation=false` on `TextField`, see TextField
+ * validation docs for more details!
+ *
+ * This example shows how LabeledTextField functionality can be mapped to
+ * LabeledField and TextField components.
+ */
+export const MigrationToLabeledField: StoryComponentType = {
+ render: function Story(args) {
+ const [labeledTextFieldValue, setLabeledTextFieldValue] =
+ React.useState("");
+ const [textFieldValue, setTextFieldValue] = React.useState("");
+ const [textFieldErrorMessage, setTextFieldErrorMessage] =
+ React.useState("");
+
+ const description = "Enter text that is at least 5 characters long.";
+ const placeholder = "Placeholder";
+ const required = "Custom required message";
+ const validate = (value: string) => {
+ if (value.length < 5) {
+ return "Should be 5 or more characters";
+ }
+ };
+ return (
+
+
+
+ }
+ />
+
+ );
+ },
+};
+
export const Text: StoryComponentType = () => {
const [value, setValue] = React.useState("Khan");
diff --git a/__docs__/wonder-blocks-labeled-field/labeled-field.stories.tsx b/__docs__/wonder-blocks-labeled-field/labeled-field.stories.tsx
index 668c51966..cc6575cef 100644
--- a/__docs__/wonder-blocks-labeled-field/labeled-field.stories.tsx
+++ b/__docs__/wonder-blocks-labeled-field/labeled-field.stories.tsx
@@ -27,6 +27,21 @@ import Button from "@khanacademy/wonder-blocks-button";
*
* It is highly recommended that all form fields should be used with the
* `LabeledField` component so that our form fields are consistent and accessible.
+ *
+ * Tips for using LabeledField:
+ * - If the `errorMessage` prop is set on `LabeledField`, the `error` prop on the
+ * form field component will be auto-populated so it doesn't need to be set
+ * explicitly on the field
+ * - If the `required` prop is set on `LabeledField`, it will be passed onto the
+ * `field` prop component so it doesn't need to be set explicitly. If the `required`
+ * prop is set on the `field` component, it will also get set for `LabeledField`
+ * so that the required indicator is shown
+ * - If the `light` prop is set on either `LabeledField` or the `field` prop,
+ * both components will render in light mode
+ * - For TextField and TextArea, it is highly recommended that they are
+ * configured with `instantValidation=false` so that validation happens on blur.
+ * See Validation docs for those components for more details!
+ *
*/
export default {
title: "Packages / LabeledField",
@@ -43,18 +58,27 @@ export default {
} as Meta;
type StoryComponentType = StoryObj;
+type AllFieldsStoryComponentType = StoryObj;
export const Default: StoryComponentType = {
args: {
field: {}} />,
label: "Name",
description: "Helpful description text.",
- error: "Message about the error",
- required: true,
+ errorMessage: "Message about the error",
+ required: "Custom required message",
},
};
-const AllFields = (args: PropsFor) => {
+const AllFields = (
+ storyArgs: PropsFor & {
+ shouldValidateInStory?: boolean;
+ showSubmitButtonInStory?: boolean;
+ },
+) => {
+ const {shouldValidateInStory, showSubmitButtonInStory, ...args} = storyArgs;
+
+ /** Values */
const [textFieldValue, setTextFieldValue] = React.useState("");
const [textAreaValue, setTextAreaValue] = React.useState("");
const [singleSelectValue, setSingleSelectValue] = React.useState("");
@@ -63,38 +87,180 @@ const AllFields = (args: PropsFor) => {
);
const [searchValue, setSearchValue] = React.useState("");
+ /** Error messages */
+ const errorMessage =
+ typeof args.errorMessage === "string" ? args.errorMessage : "";
+ const [textFieldErrorMessage, setTextFieldErrorMessage] = React.useState<
+ string | null | undefined
+ >(errorMessage);
+ const [textAreaErrorMessage, setTextAreaErrorMessage] = React.useState<
+ string | null | undefined
+ >(errorMessage);
+ const [singleSelectErrorMessage, setSingleSelectErrorMessage] =
+ React.useState(errorMessage);
+ const [multiSelectErrorMessage, setMultiSelectErrorMessage] =
+ React.useState(errorMessage);
+ const [searchErrorMessage, setSearchErrorMessage] = React.useState<
+ string | null | undefined
+ >(errorMessage);
+
+ /** Refs */
+ const textFieldRef = React.createRef();
+ const textAreaRef = React.createRef();
+ const singleSelectRef = React.createRef();
+ const multiSelectRef = React.createRef();
+ const searchRef = React.createRef();
+
+ const [isFormSubmitted, setIsFormSubmitted] = React.useState(false);
+
+ const moveFocusToFirstFieldWithError = React.useCallback(() => {
+ // The errors in the order they are presented, along with the refs
+ const errors = [
+ {message: textFieldErrorMessage, ref: textFieldRef},
+ {message: textAreaErrorMessage, ref: textAreaRef},
+ {message: singleSelectErrorMessage, ref: singleSelectRef},
+ {message: multiSelectErrorMessage, ref: multiSelectRef},
+ {message: searchErrorMessage, ref: searchRef},
+ ];
+
+ for (const error of errors) {
+ if (error.message) {
+ // Once a field with an error is found, focus on it and end the loop
+ error.ref?.current?.focus();
+ break;
+ }
+ }
+ }, [
+ multiSelectErrorMessage,
+ multiSelectRef,
+ searchErrorMessage,
+ searchRef,
+ singleSelectErrorMessage,
+ singleSelectRef,
+ textAreaErrorMessage,
+ textAreaRef,
+ textFieldErrorMessage,
+ textFieldRef,
+ ]);
+
+ React.useEffect(() => {
+ if (isFormSubmitted) {
+ // If the form has been submitted, move focus. We use useEffect
+ // so that the error message states are updated before we move focus
+ moveFocusToFirstFieldWithError();
+ setIsFormSubmitted(false);
+ }
+ }, [isFormSubmitted, moveFocusToFirstFieldWithError]);
+
+ const handleSubmit = () => {
+ const backendErrorMessage = "Example server side error message";
+ if (args.required) {
+ const requiredMsg =
+ typeof args.required === "string"
+ ? args.required
+ : "Story default required msg";
+ if (!textFieldValue) {
+ setTextFieldErrorMessage(requiredMsg);
+ }
+ if (!textAreaValue) {
+ setTextAreaErrorMessage(requiredMsg);
+ }
+ if (!singleSelectValue) {
+ setSingleSelectErrorMessage(requiredMsg);
+ }
+ if (multiSelectValue.length === 0) {
+ setMultiSelectErrorMessage(requiredMsg);
+ }
+ if (!searchValue) {
+ setSearchErrorMessage(requiredMsg);
+ }
+ } else {
+ setTextFieldErrorMessage(backendErrorMessage);
+ setTextAreaErrorMessage(backendErrorMessage);
+ setSingleSelectErrorMessage(backendErrorMessage);
+ setMultiSelectErrorMessage(backendErrorMessage);
+ setSearchErrorMessage(backendErrorMessage);
+ }
+ setIsFormSubmitted(true);
+ };
+
+ const textDescription = shouldValidateInStory
+ ? "Trigger error by entering text that is 4 characters or less"
+ : args.description;
+ const selectDescription = shouldValidateInStory
+ ? "Trigger error by selecting mango"
+ : args.description;
+
+ const textValidate = (value: string) => {
+ if (value.length < 5) {
+ return "Should be 5 or more characters";
+ }
+ };
+
+ const singleSelectValidate = (value?: string | null) => {
+ if (value === "mango") {
+ return "Don't pick mango!";
+ }
+ };
+
+ const multiSelectValidate = (values: string[]) => {
+ if (values.includes("mango")) {
+ return "Don't pick mango!";
+ }
+ };
+
return (
}
/>
}
/>
@@ -105,12 +271,20 @@ const AllFields = (args: PropsFor) => {
@@ -121,14 +295,26 @@ const AllFields = (args: PropsFor) => {
}
/>
+
+ {showSubmitButtonInStory && (
+ Submit
+ )}
);
};
@@ -140,41 +326,92 @@ const AllFields = (args: PropsFor) => {
* - `SingleSelect`
* - `MultiSelect`
* - `SearchField`
+ *
+ * LabeledField works best with field components that accept `error`, `light`,
+ * and `required` props since these props will get auto-populated by
+ * LabeledField.
*/
export const Fields: StoryComponentType = {
args: {
description: "Helpful description text.",
- required: true,
},
render: AllFields,
};
+/**
+ * The `errorMessage` prop can be used to define the error message to show for
+ * the field.
+ *
+ * It will also put the field component in an error state by
+ * auto-populating the field's `error` prop depending on if there is an error
+ * message.
+ */
export const Error: StoryComponentType = {
args: {
description: "Helpful description text.",
- error: "Message about the error",
- required: true,
+ errorMessage: "Message about the error",
},
render: AllFields,
};
/**
- * If the labeled field is used on a dark background, the `light` prop can be
- * set to `true`. When abled, the text in the component (label, required
- * indicator, description, and error message) are modified to work on a dark
- * background.
+ * If it is mandatory for a user to fill out a field, it can be marked as
+ * required.
+ *
+ * LabeledField will auto-populate the `required` prop for the field
+ * component and validation is handled by the specific field components. See
+ * docs for each component for more details on validation logic.
+ *
+ * If LabeledField's `required` prop is used and the field's `onValidate` prop
+ * sets LabeledField's `errorMessage` prop, the error message for the required
+ * field will be shown.
+ *
+ * Note: The validation around required fields is only triggered if a field is
+ * interacted with. If the form is submitted with required empty fields, it is
+ * up to the parent component to set the `errorMessage` prop on the
+ * LabeledField component.
*/
-export const Light: StoryComponentType = {
+export const Required: AllFieldsStoryComponentType = {
args: {
description: "Helpful description text.",
- error: "Message about the error",
- required: true,
- light: true,
+ required: "Custom required error message",
+ showSubmitButtonInStory: true,
},
+ render: AllFields,
parameters: {
- backgrounds: {default: "darkBlue"},
+ chromatic: {
+ // Disabling because this doesn't test anything visual.
+ disableSnapshot: true,
+ },
+ },
+};
+
+/**
+ * The LabeledField's `errorMessage` prop can be configured with the form field's
+ * validation props like `validate` and `onValidate`. This example also shows
+ * how an error message can be shown after the form is submitted.
+ *
+ * Note: For `TextField` and `TextArea` components, it is recommended to use
+ * `instantValidation=false` so that validation occurs on blur for better
+ * usability.
+ *
+ * In this example, the text-based fields will show an error if the value has
+ * less than 5 characters. The select-based fields will show an error if "Mango"
+ * is selected.
+ */
+export const Validation: AllFieldsStoryComponentType = {
+ args: {
+ description: "Helpful description text.",
+ shouldValidateInStory: true,
+ showSubmitButtonInStory: true,
},
render: AllFields,
+ parameters: {
+ chromatic: {
+ // Disabling because this doesn't test anything visual.
+ disableSnapshot: true,
+ },
+ },
};
/**
@@ -192,7 +429,7 @@ export const ChangingErrors: StoryComponentType = () => {
{}} />}
- error={errorMessage}
+ errorMessage={errorMessage}
/>
) => {
) => {
) => {
required={true}
{...args}
label={longText}
- error={longText}
+ errorMessage={longText}
description={longText}
field={
) => {
required={true}
{...args}
label={longTextWithNoBreak}
- error={longTextWithNoBreak}
+ errorMessage={longTextWithNoBreak}
description={longTextWithNoBreak}
field={
) => {
);
};
+
+/**
+ * Here is an example where LabeledField is used with a non-Wonder Blocks
+ * component.
+ *
+ * Although it can be used with custom components, it is recommended that
+ * LabeledField is used with the following Wonder Blocks components:
+ * - TextField
+ * - TextArea
+ * - SearchField
+ * - SingleSelect
+ * - MultiSelect
+ *
+ * This is recommended because LabeledField will inject WB specific props:
+ * `required`, `error`, `light`, and `testId`. The `field` component should
+ * handle these props accordingly. This is helpful because for example,
+ * if LabeledField has an error message, the field should also be in an error state.
+ * If the `field` component doesn't support these props, there will be console warnings.
+ */
+export const WithNonWb = {
+ args: {
+ label: "Label",
+ description: "Description",
+ errorMessage: "Error message",
+ required: true,
+ field: ,
+ },
+ parameters: {
+ chromatic: {
+ // Disabling because this doesn't test anything visual.
+ disableSnapshot: true,
+ },
+ },
+};
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 089d061f8..1e4822ba3 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
@@ -851,6 +851,47 @@ describe("TextArea", () => {
const textArea = await screen.findByRole("textbox");
expect(textArea).toHaveAttribute("aria-invalid", "false");
});
+
+ describe("aria-required", () => {
+ it.each([
+ {
+ required: true,
+ ariaRequired: "true",
+ },
+ {
+ required: false,
+ ariaRequired: "false",
+ },
+ {
+ required: undefined,
+ ariaRequired: "false",
+ },
+ {
+ required: "Custom required message",
+ ariaRequired: "true",
+ },
+ ])(
+ "should set the aria-required attribute to $ariaRequired if required prop is $required",
+ ({required, ariaRequired}) => {
+ // Arrange
+ // Act
+ render(
+