Skip to content

Commit

Permalink
SingleSelect validation props (#2376)
Browse files Browse the repository at this point in the history
## 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
  • Loading branch information
beaesguerra authored Dec 10, 2024
1 parent c7178e1 commit 71e7086
Show file tree
Hide file tree
Showing 11 changed files with 1,117 additions and 91 deletions.
10 changes: 10 additions & 0 deletions .changeset/mean-ligers-relax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@khanacademy/wonder-blocks-dropdown": minor
---

# SingleSelect

- Add `required`, `validate`, and `onValidate` props to support validation.
- DropdownOpener and SelectOpener:
- Add `onBlur` prop
- Set `aria-invalid` if it is in an error state.
5 changes: 5 additions & 0 deletions .changeset/smooth-poems-buy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/wonder-blocks-dropdown": patch
---

Update `DropdownCore` to check for key presses using `event.key` instead of `event.which` or `event.keyCode` (which are both deprecated now)
42 changes: 41 additions & 1 deletion __docs__/wonder-blocks-dropdown/base-select.argtypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,53 @@ const argTypes: ArgTypes = {
},

error: {
description: "Whether this component is in an error state.",
description: `Whether this component is in an error state. Use this for
errors that are triggered by something external to the component
(example: an error after form submission).`,
table: {
category: "States",
defaultValue: {summary: "false"},
},
},

required: {
description: `Whether this field is required to to continue, or the
error message to render if the select is left blank. Pass in a
message instead of "true" if possible.`,
table: {
category: "States",
type: {
summary: "boolean | string",
},
},
control: {
type: undefined,
},
},

validate: {
description: `Provide a validation for the selected value. Return a
string error message or null | void for a valid input.
\n Use this for errors that are shown to the user while they are
filling out a form.`,
table: {
category: "States",
type: {
summary: "(value: string) => ?string",
},
},
},

onValidate: {
description: "Called right after the field is validated.",
table: {
category: "Events",
type: {
summary: "(errorMessage: ?string) => mixed",
},
},
},

isFilterable: {
description: `When this is true, the dropdown body shows a search text
input top. The items will be filtered by the input. Selected items
Expand Down
143 changes: 122 additions & 21 deletions __docs__/wonder-blocks-dropdown/single-select.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-lines */
import * as React from "react";
import {StyleSheet} from "aphrodite";
import planetIcon from "@phosphor-icons/core/regular/planet.svg";
Expand All @@ -6,8 +7,8 @@ import {action} from "@storybook/addon-actions";
import type {Meta, StoryObj} from "@storybook/react";

import Button from "@khanacademy/wonder-blocks-button";
import {color, spacing} from "@khanacademy/wonder-blocks-tokens";
import {View} from "@khanacademy/wonder-blocks-core";
import {color, semanticColor, spacing} from "@khanacademy/wonder-blocks-tokens";
import {PropsFor, View} from "@khanacademy/wonder-blocks-core";
import {TextField} from "@khanacademy/wonder-blocks-form";
import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon";
import {Strut} from "@khanacademy/wonder-blocks-layout";
Expand All @@ -17,6 +18,7 @@ import {
Body,
HeadingLarge,
LabelMedium,
LabelSmall,
} from "@khanacademy/wonder-blocks-typography";
import {
SingleSelect,
Expand Down Expand Up @@ -380,39 +382,138 @@ export const Disabled: StoryComponentType = {
),
};

const ErrorWrapper = () => {
const [error, setError] = React.useState(true);
const [selectedValue, setSelectedValue] = React.useState("");
const ControlledSingleSelect = (args: PropsFor<typeof SingleSelect>) => {
const [opened, setOpened] = React.useState(false);

const [selectedValue, setSelectedValue] = React.useState(
args.selectedValue,
);
const [errorMessage, setErrorMessage] = React.useState<
null | string | void
>(null);
return (
<>
<LabelMedium style={{marginBottom: spacing.xSmall_8}}>
Select any fruit other than lemon to clear the error!
</LabelMedium>
<View style={{gap: spacing.xSmall_8}}>
<SingleSelect
error={error}
onChange={(value) => {
setSelectedValue(value);
setError(value === "lemon");
}}
onToggle={setOpened}
{...args}
id="single-select"
opened={opened}
placeholder="Choose a fruit"
onToggle={setOpened}
selectedValue={selectedValue}
onChange={setSelectedValue}
placeholder="Choose a fruit"
validate={(value) => {
if (value === "lemon") {
return "Pick another option!";
}
}}
onValidate={setErrorMessage}
>
{items}
</SingleSelect>
</>
{(errorMessage || args.error) && (
<LabelSmall
style={{color: semanticColor.status.critical.foreground}}
>
{errorMessage || "Error from error prop"}
</LabelSmall>
)}
</View>
);
};

/**
* This select is in an error state. Selecting any option other than lemon will
* clear the error state by updating 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: ControlledSingleSelect,
args: {
error: true,
},
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: ControlledSingleSelect,
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 an option is selected
*
* 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 ErrorFromValidation: StoryComponentType = {
render: (args: PropsFor<typeof SingleSelect>) => {
return (
<View style={{gap: spacing.xSmall_8}}>
<LabelSmall htmlFor="single-select" tag="label">
Validation example (try picking lemon to trigger an error)
</LabelSmall>
<ControlledSingleSelect
{...args}
id="single-select"
validate={(value) => {
if (value === "lemon") {
return "Pick another option!";
}
}}
>
{items}
</ControlledSingleSelect>
</View>
);
},
parameters: {
chromatic: {
// Disabling because this doesn't test anything visual.
disableSnapshot: true,
},
},
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import {userEvent} from "@testing-library/user-event";
import SelectOpener from "../select-opener";

describe("SelectOpener", () => {
const children = "text";
describe("onOpenChanged", () => {
const children = "text";
it("should trigger using the mouse", async () => {
// Arrange
const onOpenMock = jest.fn();
Expand Down Expand Up @@ -217,4 +217,52 @@ describe("SelectOpener", () => {
expect(onOpenMock).toHaveBeenCalledTimes(0);
});
});

describe("error prop", () => {
it.each([
{ariaInvalid: "true", error: true},
{ariaInvalid: "false", error: false},
{ariaInvalid: "false", error: undefined},
])(
"should set aria-invalid to $ariaInvalid if error is $error",
({ariaInvalid, error}) => {
// Arrange
// Act
render(
<SelectOpener
error={error}
onOpenChanged={jest.fn()}
open={false}
>
{children}
</SelectOpener>,
);
// Assert
expect(screen.getByRole("button")).toHaveAttribute(
"aria-invalid",
ariaInvalid,
);
},
);
});

it("should call onBlur when it is blurred", async () => {
// Arrange
const onBlur = jest.fn();
render(
<SelectOpener
onBlur={onBlur}
open={false}
onOpenChanged={jest.fn()}
>
{children}
</SelectOpener>,
);
await userEvent.tab(); // focus on the opener
// Act
await userEvent.tab(); // blur the opener

// Assert
expect(onBlur).toHaveBeenCalledTimes(1);
});
});
Loading

0 comments on commit 71e7086

Please sign in to comment.