Skip to content

Commit 71e7086

Browse files
authored
SingleSelect validation props (#2376)
## Summary: - Adding validation related props to SingleSelect: `validate`, `onValidate`, `required`. (`error` prop was already supported) - DropdownOpener and SelectOpener: Add optional `onBlur` prop and set `aria-invalid` based on `error` prop - DropdownCore: When checking keyboard events, check what key is pressed using [event.key](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key) instead of [event.which](https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/which) or [event.keyCode](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode) which are now deprecated. Testing keyboard interactions in tests weren't triggering the correct logic since we were using deprecated fields that [user-event doesn't support anymore](https://github.com/testing-library/user-event/releases/tag/v14.0.0): > Support for keyCode property on keyboard events has been removed. Next: Will work on applying similar changes to MultiSelect and consolidating any common logic! Issue: WB-1782 ## Test plan: - SingleSelect docs are reviewed `?path=/docs/packages-dropdown-singleselect--docs` - Validation works as expected in SingleSelect (see docs for more details on validation behaviour): - Error `?path=/story/packages-dropdown-singleselect--error` - Required `?path=/story/packages-dropdown-singleselect--required` - Error From Validation `?path=/story/packages-dropdown-singleselect--error-from-validation` - SingleSelect continues to work as expected (including keyboard interactions) 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: #2376
1 parent c7178e1 commit 71e7086

File tree

11 files changed

+1117
-91
lines changed

11 files changed

+1117
-91
lines changed

.changeset/mean-ligers-relax.md

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

.changeset/smooth-poems-buy.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@khanacademy/wonder-blocks-dropdown": patch
3+
---
4+
5+
Update `DropdownCore` to check for key presses using `event.key` instead of `event.which` or `event.keyCode` (which are both deprecated now)

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

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,53 @@ const argTypes: ArgTypes = {
3030
},
3131

3232
error: {
33-
description: "Whether this component is in an error state.",
33+
description: `Whether this component is in an error state. Use this for
34+
errors that are triggered by something external to the component
35+
(example: an error after form submission).`,
3436
table: {
3537
category: "States",
3638
defaultValue: {summary: "false"},
3739
},
3840
},
3941

42+
required: {
43+
description: `Whether this field is required to to continue, or the
44+
error message to render if the select is left blank. Pass in a
45+
message instead of "true" if possible.`,
46+
table: {
47+
category: "States",
48+
type: {
49+
summary: "boolean | string",
50+
},
51+
},
52+
control: {
53+
type: undefined,
54+
},
55+
},
56+
57+
validate: {
58+
description: `Provide a validation for the selected value. Return a
59+
string error message or null | void for a valid input.
60+
\n Use this for errors that are shown to the user while they are
61+
filling out a form.`,
62+
table: {
63+
category: "States",
64+
type: {
65+
summary: "(value: string) => ?string",
66+
},
67+
},
68+
},
69+
70+
onValidate: {
71+
description: "Called right after the field is validated.",
72+
table: {
73+
category: "Events",
74+
type: {
75+
summary: "(errorMessage: ?string) => mixed",
76+
},
77+
},
78+
},
79+
4080
isFilterable: {
4181
description: `When this is true, the dropdown body shows a search text
4282
input top. The items will be filtered by the input. Selected items

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

Lines changed: 122 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable max-lines */
12
import * as React from "react";
23
import {StyleSheet} from "aphrodite";
34
import planetIcon from "@phosphor-icons/core/regular/planet.svg";
@@ -6,8 +7,8 @@ import {action} from "@storybook/addon-actions";
67
import type {Meta, StoryObj} from "@storybook/react";
78

89
import Button from "@khanacademy/wonder-blocks-button";
9-
import {color, spacing} from "@khanacademy/wonder-blocks-tokens";
10-
import {View} from "@khanacademy/wonder-blocks-core";
10+
import {color, semanticColor, spacing} from "@khanacademy/wonder-blocks-tokens";
11+
import {PropsFor, View} from "@khanacademy/wonder-blocks-core";
1112
import {TextField} from "@khanacademy/wonder-blocks-form";
1213
import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon";
1314
import {Strut} from "@khanacademy/wonder-blocks-layout";
@@ -17,6 +18,7 @@ import {
1718
Body,
1819
HeadingLarge,
1920
LabelMedium,
21+
LabelSmall,
2022
} from "@khanacademy/wonder-blocks-typography";
2123
import {
2224
SingleSelect,
@@ -380,39 +382,138 @@ export const Disabled: StoryComponentType = {
380382
),
381383
};
382384

383-
const ErrorWrapper = () => {
384-
const [error, setError] = React.useState(true);
385-
const [selectedValue, setSelectedValue] = React.useState("");
385+
const ControlledSingleSelect = (args: PropsFor<typeof SingleSelect>) => {
386386
const [opened, setOpened] = React.useState(false);
387-
387+
const [selectedValue, setSelectedValue] = React.useState(
388+
args.selectedValue,
389+
);
390+
const [errorMessage, setErrorMessage] = React.useState<
391+
null | string | void
392+
>(null);
388393
return (
389-
<>
390-
<LabelMedium style={{marginBottom: spacing.xSmall_8}}>
391-
Select any fruit other than lemon to clear the error!
392-
</LabelMedium>
394+
<View style={{gap: spacing.xSmall_8}}>
393395
<SingleSelect
394-
error={error}
395-
onChange={(value) => {
396-
setSelectedValue(value);
397-
setError(value === "lemon");
398-
}}
399-
onToggle={setOpened}
396+
{...args}
397+
id="single-select"
400398
opened={opened}
401-
placeholder="Choose a fruit"
399+
onToggle={setOpened}
402400
selectedValue={selectedValue}
401+
onChange={setSelectedValue}
402+
placeholder="Choose a fruit"
403+
validate={(value) => {
404+
if (value === "lemon") {
405+
return "Pick another option!";
406+
}
407+
}}
408+
onValidate={setErrorMessage}
403409
>
404410
{items}
405411
</SingleSelect>
406-
</>
412+
{(errorMessage || args.error) && (
413+
<LabelSmall
414+
style={{color: semanticColor.status.critical.foreground}}
415+
>
416+
{errorMessage || "Error from error prop"}
417+
</LabelSmall>
418+
)}
419+
</View>
407420
);
408421
};
409422

410423
/**
411-
* This select is in an error state. Selecting any option other than lemon will
412-
* clear the error state by updating the `error` prop to `false`.
424+
* If the `error` prop is set to true, the field will have error styling and
425+
* `aria-invalid` set to `true`.
426+
*
427+
* This is useful for scenarios where we want to show an error on a
428+
* specific field after a form is submitted (server validation).
429+
*
430+
* Note: The `required` and `validate` props can also put the field in an
431+
* error state.
413432
*/
414433
export const Error: StoryComponentType = {
415-
render: ErrorWrapper,
434+
render: ControlledSingleSelect,
435+
args: {
436+
error: true,
437+
},
438+
parameters: {
439+
chromatic: {
440+
// Disabling because this is covered by variants story
441+
disableSnapshot: true,
442+
},
443+
},
444+
};
445+
446+
/**
447+
* A required field will have error styling and aria-invalid set to true if the
448+
* select is left blank.
449+
*
450+
* When `required` is set to `true`, validation is triggered:
451+
* - When a user tabs away from the select (opener's onBlur event)
452+
* - When a user closes the dropdown without selecting a value
453+
* (either by pressing escape, clicking away, or clicking on the opener).
454+
*
455+
* Validation errors are cleared when a valid value is selected. The component
456+
* will set aria-invalid to "false" and call the onValidate prop with null.
457+
*
458+
*/
459+
export const Required: StoryComponentType = {
460+
render: ControlledSingleSelect,
461+
args: {
462+
required: "Custom required error message",
463+
},
464+
parameters: {
465+
chromatic: {
466+
// Disabling because this doesn't test anything visual.
467+
disableSnapshot: true,
468+
},
469+
},
470+
};
471+
472+
/**
473+
* If a selected value fails validation, the field will have error styling.
474+
*
475+
* This is useful for scenarios where we want to show errors while a
476+
* user is filling out a form (client validation).
477+
*
478+
* Note that we will internally set the correct `aria-invalid` attribute to the
479+
* field:
480+
* - aria-invalid="true" if there is an error.
481+
* - aria-invalid="false" if there is no error.
482+
*
483+
* Validation is triggered:
484+
* - On mount if the `value` prop is not empty and it is not required
485+
* - When an option is selected
486+
*
487+
* Validation errors are cleared when a valid value is selected. The component
488+
* will set aria-invalid to "false" and call the onValidate prop with null.
489+
*/
490+
export const ErrorFromValidation: StoryComponentType = {
491+
render: (args: PropsFor<typeof SingleSelect>) => {
492+
return (
493+
<View style={{gap: spacing.xSmall_8}}>
494+
<LabelSmall htmlFor="single-select" tag="label">
495+
Validation example (try picking lemon to trigger an error)
496+
</LabelSmall>
497+
<ControlledSingleSelect
498+
{...args}
499+
id="single-select"
500+
validate={(value) => {
501+
if (value === "lemon") {
502+
return "Pick another option!";
503+
}
504+
}}
505+
>
506+
{items}
507+
</ControlledSingleSelect>
508+
</View>
509+
);
510+
},
511+
parameters: {
512+
chromatic: {
513+
// Disabling because this doesn't test anything visual.
514+
disableSnapshot: true,
515+
},
516+
},
416517
};
417518

418519
/**

packages/wonder-blocks-dropdown/src/components/__tests__/select-opener.test.tsx

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import {userEvent} from "@testing-library/user-event";
55
import SelectOpener from "../select-opener";
66

77
describe("SelectOpener", () => {
8+
const children = "text";
89
describe("onOpenChanged", () => {
9-
const children = "text";
1010
it("should trigger using the mouse", async () => {
1111
// Arrange
1212
const onOpenMock = jest.fn();
@@ -217,4 +217,52 @@ describe("SelectOpener", () => {
217217
expect(onOpenMock).toHaveBeenCalledTimes(0);
218218
});
219219
});
220+
221+
describe("error prop", () => {
222+
it.each([
223+
{ariaInvalid: "true", error: true},
224+
{ariaInvalid: "false", error: false},
225+
{ariaInvalid: "false", error: undefined},
226+
])(
227+
"should set aria-invalid to $ariaInvalid if error is $error",
228+
({ariaInvalid, error}) => {
229+
// Arrange
230+
// Act
231+
render(
232+
<SelectOpener
233+
error={error}
234+
onOpenChanged={jest.fn()}
235+
open={false}
236+
>
237+
{children}
238+
</SelectOpener>,
239+
);
240+
// Assert
241+
expect(screen.getByRole("button")).toHaveAttribute(
242+
"aria-invalid",
243+
ariaInvalid,
244+
);
245+
},
246+
);
247+
});
248+
249+
it("should call onBlur when it is blurred", async () => {
250+
// Arrange
251+
const onBlur = jest.fn();
252+
render(
253+
<SelectOpener
254+
onBlur={onBlur}
255+
open={false}
256+
onOpenChanged={jest.fn()}
257+
>
258+
{children}
259+
</SelectOpener>,
260+
);
261+
await userEvent.tab(); // focus on the opener
262+
// Act
263+
await userEvent.tab(); // blur the opener
264+
265+
// Assert
266+
expect(onBlur).toHaveBeenCalledTimes(1);
267+
});
220268
});

0 commit comments

Comments
 (0)