Skip to content

Commit bc4da9e

Browse files
authored
Add validation props to MultiSelect and share logic with SingleSelect (#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
1 parent 71e7086 commit bc4da9e

File tree

10 files changed

+2201
-104
lines changed

10 files changed

+2201
-104
lines changed

.changeset/thin-gifts-tickle.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@khanacademy/wonder-blocks-dropdown": minor
3+
---
4+
5+
# MultiSelect
6+
7+
- Add `required`, `validate`, and `onValidate` props to support validation.
8+
- Set `aria-invalid` on the opener if it is in an error state
9+
- Share validation logic with SingleSelect

__docs__/wonder-blocks-dropdown/multi-select.argtypes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const argTypes: ArgTypes = {
1212
table: {
1313
type: {summary: "Array<string>"},
1414
},
15+
control: {type: "object"},
1516
},
1617
labels: {
1718
control: {type: "object"},

__docs__/wonder-blocks-dropdown/multi-select.stories.tsx

Lines changed: 120 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ import {StyleSheet} from "aphrodite";
33

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

88
import Button from "@khanacademy/wonder-blocks-button";
99
import {Checkbox} from "@khanacademy/wonder-blocks-form";
1010
import {OnePaneDialog, ModalLauncher} from "@khanacademy/wonder-blocks-modal";
11-
import {color, spacing} from "@khanacademy/wonder-blocks-tokens";
11+
import {color, semanticColor, spacing} from "@khanacademy/wonder-blocks-tokens";
1212
import {HeadingLarge, LabelMedium} from "@khanacademy/wonder-blocks-typography";
1313
import {MultiSelect, OptionItem} from "@khanacademy/wonder-blocks-dropdown";
1414
import Pill from "@khanacademy/wonder-blocks-pill";
@@ -129,14 +129,14 @@ const styles = StyleSheet.create({
129129
});
130130

131131
const items = [
132-
<OptionItem label="Mercury" value="1" key={1} />,
133-
<OptionItem label="Venus" value="2" key={2} />,
134-
<OptionItem label="Earth" value="3" disabled key={3} />,
135-
<OptionItem label="Mars" value="4" key={4} />,
136-
<OptionItem label="Jupiter" value="5" key={5} />,
137-
<OptionItem label="Saturn" value="6" key={6} />,
138-
<OptionItem label="Neptune" value="7" key={7} />,
139-
<OptionItem label="Uranus" value="8" key={8} />,
132+
<OptionItem label="Mercury" value="mercury" key={1} />,
133+
<OptionItem label="Venus" value="venus" key={2} />,
134+
<OptionItem label="Earth" value="earth" disabled key={3} />,
135+
<OptionItem label="Mars" value="mars" key={4} />,
136+
<OptionItem label="Jupiter" value="jupiter" key={5} />,
137+
<OptionItem label="Saturn" value="saturn" key={6} />,
138+
<OptionItem label="Neptune" value="neptune" key={7} />,
139+
<OptionItem label="Uranus" value="uranus" key={8} />,
140140
];
141141

142142
const Template = (args: any) => {
@@ -271,42 +271,133 @@ export const CustomStylesOpened: StoryComponentType = {
271271
],
272272
};
273273

274-
const ErrorWrapper = (args: any) => {
275-
const [selectedValues, setSelectedValues] = React.useState<string[]>([]);
274+
const ControlledMultiSelect = (args: PropsFor<typeof MultiSelect>) => {
276275
const [opened, setOpened] = React.useState(false);
277-
const [error, setError] = React.useState(true);
278-
276+
const [selectedValues, setSelectedValues] = React.useState<string[]>(
277+
args.selectedValues || [],
278+
);
279+
const [errorMessage, setErrorMessage] = React.useState<
280+
null | string | void
281+
>(null);
279282
return (
280-
<>
281-
<LabelMedium style={{marginBottom: spacing.xSmall_8}}>
282-
Select at least 2 options to clear the error!
283-
</LabelMedium>
283+
<View style={{gap: spacing.xSmall_8}}>
284284
<MultiSelect
285285
{...args}
286-
error={error}
287-
onChange={(values) => {
288-
setSelectedValues(values);
289-
setError(values.length < 2);
290-
}}
291-
onToggle={setOpened}
286+
id="multi-select"
292287
opened={opened}
288+
onToggle={setOpened}
293289
selectedValues={selectedValues}
290+
onChange={setSelectedValues}
291+
validate={(values) => {
292+
if (values.includes("jupiter")) {
293+
return "Don't pick jupiter!";
294+
}
295+
}}
296+
onValidate={setErrorMessage}
294297
>
295298
{items}
296299
</MultiSelect>
297-
</>
300+
{(errorMessage || args.error) && (
301+
<LabelMedium
302+
style={{color: semanticColor.status.critical.foreground}}
303+
>
304+
{errorMessage || "Error from error prop"}
305+
</LabelMedium>
306+
)}
307+
</View>
298308
);
299309
};
300310

301311
/**
302-
* Here is an example of a dropdown that is in an error state. Selecting two or
303-
* more options will clear the error by setting the `error` prop to `false`.
312+
* If the `error` prop is set to true, the field will have error styling and
313+
* `aria-invalid` set to `true`.
314+
*
315+
* This is useful for scenarios where we want to show an error on a
316+
* specific field after a form is submitted (server validation).
317+
*
318+
* Note: The `required` and `validate` props can also put the field in an
319+
* error state.
304320
*/
305321
export const Error: StoryComponentType = {
306-
render: ErrorWrapper,
322+
render: ControlledMultiSelect,
307323
args: {
308324
error: true,
309-
} as MultiSelectArgs,
325+
},
326+
parameters: {
327+
chromatic: {
328+
// Disabling because this is covered by variants story
329+
disableSnapshot: true,
330+
},
331+
},
332+
};
333+
334+
/**
335+
* A required field will have error styling and aria-invalid set to true if the
336+
* select is left blank.
337+
*
338+
* When `required` is set to `true`, validation is triggered:
339+
* - When a user tabs away from the select (opener's onBlur event)
340+
* - When a user closes the dropdown without selecting a value
341+
* (either by pressing escape, clicking away, or clicking on the opener).
342+
*
343+
* Validation errors are cleared when a valid value is selected. The component
344+
* will set aria-invalid to "false" and call the onValidate prop with null.
345+
*
346+
*/
347+
export const Required: StoryComponentType = {
348+
render: ControlledMultiSelect,
349+
args: {
350+
required: "Custom required error message",
351+
},
352+
parameters: {
353+
chromatic: {
354+
// Disabling because this doesn't test anything visual.
355+
disableSnapshot: true,
356+
},
357+
},
358+
};
359+
360+
/**
361+
* If a selected value fails validation, the field will have error styling.
362+
*
363+
* This is useful for scenarios where we want to show errors while a
364+
* user is filling out a form (client validation).
365+
*
366+
* Note that we will internally set the correct `aria-invalid` attribute to the
367+
* field:
368+
* - aria-invalid="true" if there is an error.
369+
* - aria-invalid="false" if there is no error.
370+
*
371+
* Validation is triggered:
372+
* - On mount if the `value` prop is not empty and it is not required
373+
* - When the dropdown is closed after updating the selected values
374+
*
375+
* Validation errors are cleared when the value is updated. The component
376+
* will set aria-invalid to "false" and call the onValidate prop with null.
377+
*/
378+
export const ErrorFromValidation: StoryComponentType = {
379+
render: (args: PropsFor<typeof MultiSelect>) => {
380+
return (
381+
<View style={{gap: spacing.xSmall_8}}>
382+
<LabelMedium htmlFor="multi-select" tag="label">
383+
Validation example (try picking jupiter)
384+
</LabelMedium>
385+
<ControlledMultiSelect {...args} id="multi-select">
386+
{items}
387+
</ControlledMultiSelect>
388+
<LabelMedium htmlFor="multi-select" tag="label">
389+
Validation example (on mount)
390+
</LabelMedium>
391+
<ControlledMultiSelect
392+
{...args}
393+
selectedValues={["jupiter"]}
394+
id="multi-select"
395+
>
396+
{items}
397+
</ControlledMultiSelect>
398+
</View>
399+
);
400+
},
310401
};
311402

312403
/**

__docs__/wonder-blocks-dropdown/single-select.stories.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -505,15 +505,24 @@ export const ErrorFromValidation: StoryComponentType = {
505505
>
506506
{items}
507507
</ControlledSingleSelect>
508+
<LabelSmall htmlFor="single-select" tag="label">
509+
Validation example (on mount)
510+
</LabelSmall>
511+
<ControlledSingleSelect
512+
{...args}
513+
id="single-select"
514+
validate={(value) => {
515+
if (value === "lemon") {
516+
return "Pick another option!";
517+
}
518+
}}
519+
selectedValue="lemon"
520+
>
521+
{items}
522+
</ControlledSingleSelect>
508523
</View>
509524
);
510525
},
511-
parameters: {
512-
chromatic: {
513-
// Disabling because this doesn't test anything visual.
514-
disableSnapshot: true,
515-
},
516-
},
517526
};
518527

519528
/**

0 commit comments

Comments
 (0)