Skip to content

Commit

Permalink
Add validation props to MultiSelect and share logic with SingleSelect (
Browse files Browse the repository at this point in the history
…#2378)

## Summary:
- Refactor SingleSelect validation logic to useSelectValidation hook
- Adding validation related props to MultiSelect: validate, onValidate, required. (error prop was already supported)
  - Make sure aria-invalid is set if it is in an error state

Issue: WB-1782

## Test plan:
- MultiSelect docs are reviewed `?path=/docs/packages-dropdown-multiselect--docs`
- Validation works as expected in MultiSelect (see docs for more details on validation behaviour):
  - Error (`?path=/story/packages-dropdown-multiselect--error`)
  - Required (`?path=/story/packages-dropdown-multiselect--required`)
  - Error from Validation (`?path=/story/packages-dropdown-multiselect--error-from-validation`)
- MultiSelect continues to work as expected (including keyboard interactions)
- SingleSelect continues to work as expected (including validation)

Author: beaesguerra

Reviewers: beaesguerra, jandrade

Required Reviewers:

Approved By: jandrade

Checks: ✅ Chromatic - Get results on regular PRs (ubuntu-latest, 20.x), ✅ Test / Test (ubuntu-latest, 20.x, 2/2), ✅ Test / Test (ubuntu-latest, 20.x, 1/2), ✅ Lint / Lint (ubuntu-latest, 20.x), ✅ Check build sizes (ubuntu-latest, 20.x), ✅ Chromatic - Build on regular PRs / chromatic (ubuntu-latest, 20.x), ✅ Publish npm snapshot (ubuntu-latest, 20.x), ⏭️  Chromatic - Skip on Release PR (changesets), ✅ Prime node_modules cache for primary configuration (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ gerald, ⏭️  dependabot

Pull Request URL: #2378
  • Loading branch information
beaesguerra authored Dec 10, 2024
1 parent 71e7086 commit bc4da9e
Show file tree
Hide file tree
Showing 10 changed files with 2,201 additions and 104 deletions.
9 changes: 9 additions & 0 deletions .changeset/thin-gifts-tickle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@khanacademy/wonder-blocks-dropdown": minor
---

# MultiSelect

- Add `required`, `validate`, and `onValidate` props to support validation.
- Set `aria-invalid` on the opener if it is in an error state
- Share validation logic with SingleSelect
1 change: 1 addition & 0 deletions __docs__/wonder-blocks-dropdown/multi-select.argtypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const argTypes: ArgTypes = {
table: {
type: {summary: "Array<string>"},
},
control: {type: "object"},
},
labels: {
control: {type: "object"},
Expand Down
149 changes: 120 additions & 29 deletions __docs__/wonder-blocks-dropdown/multi-select.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import {StyleSheet} from "aphrodite";

import {action} from "@storybook/addon-actions";
import type {Meta, StoryObj} from "@storybook/react";
import {View} from "@khanacademy/wonder-blocks-core";
import {PropsFor, View} from "@khanacademy/wonder-blocks-core";

import Button from "@khanacademy/wonder-blocks-button";
import {Checkbox} from "@khanacademy/wonder-blocks-form";
import {OnePaneDialog, ModalLauncher} from "@khanacademy/wonder-blocks-modal";
import {color, spacing} from "@khanacademy/wonder-blocks-tokens";
import {color, semanticColor, spacing} from "@khanacademy/wonder-blocks-tokens";
import {HeadingLarge, LabelMedium} from "@khanacademy/wonder-blocks-typography";
import {MultiSelect, OptionItem} from "@khanacademy/wonder-blocks-dropdown";
import Pill from "@khanacademy/wonder-blocks-pill";
Expand Down Expand Up @@ -129,14 +129,14 @@ const styles = StyleSheet.create({
});

const items = [
<OptionItem label="Mercury" value="1" key={1} />,
<OptionItem label="Venus" value="2" key={2} />,
<OptionItem label="Earth" value="3" disabled key={3} />,
<OptionItem label="Mars" value="4" key={4} />,
<OptionItem label="Jupiter" value="5" key={5} />,
<OptionItem label="Saturn" value="6" key={6} />,
<OptionItem label="Neptune" value="7" key={7} />,
<OptionItem label="Uranus" value="8" key={8} />,
<OptionItem label="Mercury" value="mercury" key={1} />,
<OptionItem label="Venus" value="venus" key={2} />,
<OptionItem label="Earth" value="earth" disabled key={3} />,
<OptionItem label="Mars" value="mars" key={4} />,
<OptionItem label="Jupiter" value="jupiter" key={5} />,
<OptionItem label="Saturn" value="saturn" key={6} />,
<OptionItem label="Neptune" value="neptune" key={7} />,
<OptionItem label="Uranus" value="uranus" key={8} />,
];

const Template = (args: any) => {
Expand Down Expand Up @@ -271,42 +271,133 @@ export const CustomStylesOpened: StoryComponentType = {
],
};

const ErrorWrapper = (args: any) => {
const [selectedValues, setSelectedValues] = React.useState<string[]>([]);
const ControlledMultiSelect = (args: PropsFor<typeof MultiSelect>) => {
const [opened, setOpened] = React.useState(false);
const [error, setError] = React.useState(true);

const [selectedValues, setSelectedValues] = React.useState<string[]>(
args.selectedValues || [],
);
const [errorMessage, setErrorMessage] = React.useState<
null | string | void
>(null);
return (
<>
<LabelMedium style={{marginBottom: spacing.xSmall_8}}>
Select at least 2 options to clear the error!
</LabelMedium>
<View style={{gap: spacing.xSmall_8}}>
<MultiSelect
{...args}
error={error}
onChange={(values) => {
setSelectedValues(values);
setError(values.length < 2);
}}
onToggle={setOpened}
id="multi-select"
opened={opened}
onToggle={setOpened}
selectedValues={selectedValues}
onChange={setSelectedValues}
validate={(values) => {
if (values.includes("jupiter")) {
return "Don't pick jupiter!";
}
}}
onValidate={setErrorMessage}
>
{items}
</MultiSelect>
</>
{(errorMessage || args.error) && (
<LabelMedium
style={{color: semanticColor.status.critical.foreground}}
>
{errorMessage || "Error from error prop"}
</LabelMedium>
)}
</View>
);
};

/**
* Here is an example of a dropdown that is in an error state. Selecting two or
* more options will clear the error by setting the `error` prop to `false`.
* If the `error` prop is set to true, the field will have error styling and
* `aria-invalid` set to `true`.
*
* This is useful for scenarios where we want to show an error on a
* specific field after a form is submitted (server validation).
*
* Note: The `required` and `validate` props can also put the field in an
* error state.
*/
export const Error: StoryComponentType = {
render: ErrorWrapper,
render: ControlledMultiSelect,
args: {
error: true,
} as MultiSelectArgs,
},
parameters: {
chromatic: {
// Disabling because this is covered by variants story
disableSnapshot: true,
},
},
};

/**
* A required field will have error styling and aria-invalid set to true if the
* select is left blank.
*
* When `required` is set to `true`, validation is triggered:
* - When a user tabs away from the select (opener's onBlur event)
* - When a user closes the dropdown without selecting a value
* (either by pressing escape, clicking away, or clicking on the opener).
*
* Validation errors are cleared when a valid value is selected. The component
* will set aria-invalid to "false" and call the onValidate prop with null.
*
*/
export const Required: StoryComponentType = {
render: ControlledMultiSelect,
args: {
required: "Custom required error message",
},
parameters: {
chromatic: {
// Disabling because this doesn't test anything visual.
disableSnapshot: true,
},
},
};

/**
* If a selected value fails validation, the field will have error styling.
*
* This is useful for scenarios where we want to show errors while a
* user is filling out a form (client validation).
*
* Note that we will internally set the correct `aria-invalid` attribute to the
* field:
* - aria-invalid="true" if there is an error.
* - aria-invalid="false" if there is no error.
*
* Validation is triggered:
* - On mount if the `value` prop is not empty and it is not required
* - When the dropdown is closed after updating the selected values
*
* Validation errors are cleared when the value is updated. The component
* will set aria-invalid to "false" and call the onValidate prop with null.
*/
export const ErrorFromValidation: StoryComponentType = {
render: (args: PropsFor<typeof MultiSelect>) => {
return (
<View style={{gap: spacing.xSmall_8}}>
<LabelMedium htmlFor="multi-select" tag="label">
Validation example (try picking jupiter)
</LabelMedium>
<ControlledMultiSelect {...args} id="multi-select">
{items}
</ControlledMultiSelect>
<LabelMedium htmlFor="multi-select" tag="label">
Validation example (on mount)
</LabelMedium>
<ControlledMultiSelect
{...args}
selectedValues={["jupiter"]}
id="multi-select"
>
{items}
</ControlledMultiSelect>
</View>
);
},
};

/**
Expand Down
21 changes: 15 additions & 6 deletions __docs__/wonder-blocks-dropdown/single-select.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -505,15 +505,24 @@ export const ErrorFromValidation: StoryComponentType = {
>
{items}
</ControlledSingleSelect>
<LabelSmall htmlFor="single-select" tag="label">
Validation example (on mount)
</LabelSmall>
<ControlledSingleSelect
{...args}
id="single-select"
validate={(value) => {
if (value === "lemon") {
return "Pick another option!";
}
}}
selectedValue="lemon"
>
{items}
</ControlledSingleSelect>
</View>
);
},
parameters: {
chromatic: {
// Disabling because this doesn't test anything visual.
disableSnapshot: true,
},
},
};

/**
Expand Down
Loading

0 comments on commit bc4da9e

Please sign in to comment.