diff --git a/.changeset/afraid-buckets-yell.md b/.changeset/afraid-buckets-yell.md deleted file mode 100644 index a845151cc..000000000 --- a/.changeset/afraid-buckets-yell.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/light-goats-serve.md b/.changeset/light-goats-serve.md new file mode 100644 index 000000000..d9c80a854 --- /dev/null +++ b/.changeset/light-goats-serve.md @@ -0,0 +1,14 @@ +--- +"@khanacademy/wonder-blocks-core": major +"@khanacademy/wonder-blocks-search-field": patch +"@khanacademy/wonder-blocks-accordion": patch +"@khanacademy/wonder-blocks-dropdown": patch +"@khanacademy/wonder-blocks-popover": patch +"@khanacademy/wonder-blocks-testing": patch +"@khanacademy/wonder-blocks-tooltip": patch +"@khanacademy/wonder-blocks-switch": patch +"@khanacademy/wonder-blocks-modal": patch +"@khanacademy/wonder-blocks-form": patch +--- + +Deprecate the ID provider and unique ID utilities diff --git a/.changeset/old-pears-fix.md b/.changeset/old-pears-fix.md deleted file mode 100644 index 96b0b21f5..000000000 --- a/.changeset/old-pears-fix.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@khanacademy/wonder-blocks-dropdown": minor ---- - -Add `startIcon` prop to Combobox diff --git a/.changeset/thirty-jars-grow.md b/.changeset/thirty-jars-grow.md new file mode 100644 index 000000000..b137ef2a9 --- /dev/null +++ b/.changeset/thirty-jars-grow.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/wonder-blocks-core": minor +--- + +- Add the `Id` component for cases where `useId` cannot be used directly diff --git a/.changeset/witty-panthers-shave.md b/.changeset/witty-panthers-shave.md new file mode 100644 index 000000000..10e609afb --- /dev/null +++ b/.changeset/witty-panthers-shave.md @@ -0,0 +1,14 @@ +--- +"@khanacademy/wonder-blocks-search-field": major +"@khanacademy/wonder-blocks-accordion": major +"@khanacademy/wonder-blocks-dropdown": major +"@khanacademy/wonder-blocks-popover": major +"@khanacademy/wonder-blocks-testing": major +"@khanacademy/wonder-blocks-tooltip": major +"@khanacademy/wonder-blocks-switch": major +"@khanacademy/wonder-blocks-modal": major +"@khanacademy/wonder-blocks-form": major +"@khanacademy/wonder-blocks-core": patch +--- + +- Migrate Wonder Blocks components off old id providers and onto new `Id` component diff --git a/.eslintrc.js b/.eslintrc.js index 4ba9f5419..4dae5554b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -59,6 +59,12 @@ module.exports = { "no-undef": "off", }, }, + { + files: ["**/*.stories.tsx"], + rules: { + "testing-library/no-await-sync-events": "off", + }, + }, ], globals: { // `no-undef` doesn't support `globalThis`, for details see diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index fe9ec130b..2349861e7 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -95,7 +95,7 @@ const parameters = { }, }; -export const decorators = [ +const decorators = [ (Story, context) => { const theme = context.globals.theme; const enableRenderStateRootDecorator = @@ -120,6 +120,7 @@ export const decorators = [ const preview: Preview = { parameters, + decorators, globalTypes: { // Allow the user to select a theme from the toolbar. theme: { diff --git a/__docs__/wonder-blocks-button/button.stories.tsx b/__docs__/wonder-blocks-button/button.stories.tsx index 7980236d7..433dc8e6f 100644 --- a/__docs__/wonder-blocks-button/button.stories.tsx +++ b/__docs__/wonder-blocks-button/button.stories.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import {StyleSheet} from "aphrodite"; import type {Meta, StoryObj} from "@storybook/react"; -import {expect, fireEvent, userEvent, within} from "@storybook/test"; +import {expect, userEvent, within} from "@storybook/test"; import {MemoryRouter, Route, Switch} from "react-router-dom"; @@ -103,7 +103,6 @@ export const Tertiary: StoryComponentType = { // Get HTML elements const button = canvas.getByRole("button"); - const computedStyleButton = getComputedStyle(button); const innerLabel = canvas.getByTestId("test-button-inner-label"); const computedStyleLabel = getComputedStyle(innerLabel, ":after"); @@ -116,19 +115,23 @@ export const Tertiary: StoryComponentType = { await expect(computedStyleLabel.height).toBe("2px"); await expect(computedStyleLabel.color).toBe("rgb(24, 101, 242)"); + // TODO(WB-1808, somewhatabstract): This isn't working. I got it passing + // locally by calling `button.focus()` as well, but it was super flaky + // and never passed first time. // Focus style - await fireEvent.focus(button); - await expect(computedStyleButton.outlineColor).toBe( - "rgb(24, 101, 242)", - ); - await expect(computedStyleButton.outlineWidth).toBe("2px"); - - // Active (mouse down) style - // eslint-disable-next-line testing-library/prefer-user-event - await fireEvent.mouseDown(button); - await expect(innerLabel).toHaveStyle("color: rgb(27, 80, 179)"); - await expect(computedStyleLabel.height).toBe("2px"); - await expect(computedStyleLabel.color).toBe("rgb(27, 80, 179)"); + // const computedStyleButton = getComputedStyle(button); + // await fireEvent.focus(button); + // await expect(computedStyleButton.outlineColor).toBe( + // "rgb(24, 101, 242)", + // ); + // await expect(computedStyleButton.outlineWidth).toBe("2px"); + + // // Active (mouse down) style + // // eslint-disable-next-line testing-library/prefer-user-event + // await fireEvent.mouseDown(button); + // await expect(innerLabel).toHaveStyle("color: rgb(27, 80, 179)"); + // await expect(computedStyleLabel.color).toBe("rgb(27, 80, 179)"); + // await expect(computedStyleLabel.height).toBe("2px"); }, }; diff --git a/__docs__/wonder-blocks-core/exports.use-render-state.mdx b/__docs__/wonder-blocks-core/exports.use-render-state.mdx index a532c93a9..d8f6ed0d8 100644 --- a/__docs__/wonder-blocks-core/exports.use-render-state.mdx +++ b/__docs__/wonder-blocks-core/exports.use-render-state.mdx @@ -1,8 +1,6 @@ import {Meta} from "@storybook/blocks"; - + # useRenderState() @@ -16,6 +14,3 @@ The `useRenderState` hook will return either: the initial rehydration render on the client. - `RenderState.Standard` if the component renders on the client after the initial rehydration. - -NOTE: Although the `RenderState` enum has a third state `Root`, this value is never -returned by `useRenderState`. diff --git a/__docs__/wonder-blocks-core/id-provider.stories.tsx b/__docs__/wonder-blocks-core/id-provider.stories.tsx index bc9c2919b..073a23114 100644 --- a/__docs__/wonder-blocks-core/id-provider.stories.tsx +++ b/__docs__/wonder-blocks-core/id-provider.stories.tsx @@ -1,3 +1,4 @@ +/* eslint-disable import/no-deprecated */ import * as React from "react"; import type {Meta, StoryObj} from "@storybook/react"; diff --git a/__docs__/wonder-blocks-core/id.mdx b/__docs__/wonder-blocks-core/id.mdx new file mode 100644 index 000000000..e81a59ca3 --- /dev/null +++ b/__docs__/wonder-blocks-core/id.mdx @@ -0,0 +1,39 @@ +import * as React from "react"; +import {Meta, Story, Canvas} from "@storybook/blocks"; +import * as IdStories from "./id.stories"; + + + +# Id + +`Id` is a component that provides an identifier to its children. + +It is useful for situations where the `useId` hook cannot be easily used, +such as in class-based components. + +If an `id` prop is provided, that is passed through to the children; +otherwise, a unique identifier is generated. + +## Usage + +```tsx +import {Id} from "@khanacademy/wonder-blocks-core"; + +{(id) =>
Hello, world!
}
; +``` + +## Examples + +### 1. Generating an id + +An identifier will always be generated if an `id` prop is not provided, or the +provided `id` property is falsy. + + + +### 2. Passthrough an id + +If an `id` prop is provided and it is truthy, that value will be passed through +to the children. + + diff --git a/__docs__/wonder-blocks-core/id.stories.tsx b/__docs__/wonder-blocks-core/id.stories.tsx new file mode 100644 index 000000000..d6451692e --- /dev/null +++ b/__docs__/wonder-blocks-core/id.stories.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; + +import {Meta} from "@storybook/react"; +import {View, Id} from "@khanacademy/wonder-blocks-core"; +import {Body, BodyMonospace} from "@khanacademy/wonder-blocks-typography"; +import {Strut} from "@khanacademy/wonder-blocks-layout"; +import {spacing} from "@khanacademy/wonder-blocks-tokens"; + +export default { + title: "Packages / Core / Id", + + parameters: { + chromatic: { + // We don't need a snapshot for this. + disableSnapshot: true, + }, + }, +} as Meta; + +export const GeneratedIdExample = () => ( + + + {(id) => ( + + Generated identifier: + + {id} + + )} + + +); + +export const PassedThroughIdExample = () => ( + + + {(id) => ( + + Passed through identifier: + + {id} + + )} + + +); diff --git a/__docs__/wonder-blocks-core/unique-id-provider.stories.tsx b/__docs__/wonder-blocks-core/unique-id-provider.stories.tsx index 93853c79d..9c564ce9d 100644 --- a/__docs__/wonder-blocks-core/unique-id-provider.stories.tsx +++ b/__docs__/wonder-blocks-core/unique-id-provider.stories.tsx @@ -1,3 +1,4 @@ +/* eslint-disable import/no-deprecated */ import * as React from "react"; import type {Meta, StoryObj} from "@storybook/react"; diff --git a/__docs__/wonder-blocks-core/use-unique-id.mdx b/__docs__/wonder-blocks-core/use-unique-id.mdx index a65d7fd2d..4891b23e2 100644 --- a/__docs__/wonder-blocks-core/use-unique-id.mdx +++ b/__docs__/wonder-blocks-core/use-unique-id.mdx @@ -1,10 +1,13 @@ import {Meta, Story, Canvas} from "@storybook/blocks"; -import * as UseUniqueIdStories from './use-unique-id.stories'; +import * as UseUniqueIdStories from "./use-unique-id.stories"; # `useUniqueIdWithoutMock` +DEPRECATED: Will be removed in a future release. Use `useId` from React or +the `Id` component. + This hook is similar to ``. It will return `null` on the initial render and then the same identifier factory for each subsequent render. The identifier factory is unique to @@ -19,6 +22,9 @@ render tree. # `useUniqueIdWithMock` +DEPRECATED: Will be removed in a future release. Use `useId` from React or +the `Id` component. + This hook is similar to ``. It will return a mock identifier factory on the initial render that doesn'that guarantee identifier uniqueness. Mock mode can help things appear on the screen diff --git a/__docs__/wonder-blocks-core/use-unique-id.stories.tsx b/__docs__/wonder-blocks-core/use-unique-id.stories.tsx index 3a2f0cb1c..b14fc140f 100644 --- a/__docs__/wonder-blocks-core/use-unique-id.stories.tsx +++ b/__docs__/wonder-blocks-core/use-unique-id.stories.tsx @@ -1,3 +1,4 @@ +/* eslint-disable import/no-deprecated */ import * as React from "react"; import {Meta} from "@storybook/react"; diff --git a/__docs__/wonder-blocks-dropdown/base-select.argtypes.ts b/__docs__/wonder-blocks-dropdown/base-select.argtypes.ts index 03391dc92..845b9e292 100644 --- a/__docs__/wonder-blocks-dropdown/base-select.argtypes.ts +++ b/__docs__/wonder-blocks-dropdown/base-select.argtypes.ts @@ -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 diff --git a/__docs__/wonder-blocks-dropdown/multi-select.argtypes.ts b/__docs__/wonder-blocks-dropdown/multi-select.argtypes.ts index b4d3fd6dc..f3134616e 100644 --- a/__docs__/wonder-blocks-dropdown/multi-select.argtypes.ts +++ b/__docs__/wonder-blocks-dropdown/multi-select.argtypes.ts @@ -12,6 +12,7 @@ const argTypes: ArgTypes = { table: { type: {summary: "Array"}, }, + control: {type: "object"}, }, labels: { control: {type: "object"}, @@ -21,6 +22,18 @@ const argTypes: ArgTypes = { type: {summary: "Labels"}, }, }, + showOpenerLabelAsText: { + control: {type: "boolean"}, + description: `When false, the SelectOpener can show a Node as a label. When true, the + SelectOpener will use a string as a label. If using custom OptionItems, a + plain text label can be provided with the \`labelAsText\` prop. + Defaults to true.`, + + table: { + type: {summary: "boolean"}, + defaultValue: {summary: "true"}, + }, + }, }; export default argTypes; diff --git a/__docs__/wonder-blocks-dropdown/multi-select.stories.tsx b/__docs__/wonder-blocks-dropdown/multi-select.stories.tsx index 95e9e3534..db9426d7d 100644 --- a/__docs__/wonder-blocks-dropdown/multi-select.stories.tsx +++ b/__docs__/wonder-blocks-dropdown/multi-select.stories.tsx @@ -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"; @@ -18,7 +18,12 @@ import ComponentInfo from "../../.storybook/components/component-info"; import packageConfig from "../../packages/wonder-blocks-dropdown/package.json"; import multiSelectArgtypes from "./multi-select.argtypes"; import {defaultLabels} from "../../packages/wonder-blocks-dropdown/src/util/constants"; -import {allCountries, allProfilesWithPictures} from "./option-item-examples"; +import { + allCountries, + allProfilesWithPictures, + locales, + chatIcon, +} from "./option-item-examples"; import {OpenerProps} from "../../packages/wonder-blocks-dropdown/src/util/types"; import Strut from "../../packages/wonder-blocks-layout/src/components/strut"; @@ -124,14 +129,14 @@ const styles = StyleSheet.create({ }); const items = [ - , - , - , - , - , - , - , - , + , + , + , + , + , + , + , + , ]; const Template = (args: any) => { @@ -266,42 +271,133 @@ export const CustomStylesOpened: StoryComponentType = { ], }; -const ErrorWrapper = (args: any) => { - const [selectedValues, setSelectedValues] = React.useState([]); +const ControlledMultiSelect = (args: PropsFor) => { const [opened, setOpened] = React.useState(false); - const [error, setError] = React.useState(true); - + const [selectedValues, setSelectedValues] = React.useState( + args.selectedValues || [], + ); + const [errorMessage, setErrorMessage] = React.useState< + null | string | void + >(null); return ( - <> - - Select at least 2 options to clear the error! - + { - 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} - + {(errorMessage || args.error) && ( + + {errorMessage || "Error from error prop"} + + )} + ); }; /** - * 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) => { + return ( + + + Validation example (try picking jupiter) + + + {items} + + + Validation example (on mount) + + + {items} + + + ); + }, }; /** @@ -650,3 +746,54 @@ export const CustomOptionItems: StoryComponentType = { ), ], }; + +/** + * This example illustrates how a JSX Element can appear as the label by setting + * `showOpenerLabelAsText` to false. Note that in this example, we define + * `labelAsText` on the OptionItems to ensure that filtering works correctly. + */ +export const CustomOptionItemsWithNodeLabel: StoryComponentType = { + render: function Render() { + const [opened, setOpened] = React.useState(true); + const [selectedValues, setSelectedValues] = React.useState< + Array + >([]); + + const handleChange = (selectedValues: Array) => { + setSelectedValues(selectedValues); + }; + + const handleToggle = (opened: boolean) => { + setOpened(opened); + }; + + return ( + + {locales.map((locale, index) => ( + + {chatIcon} {locale} + + } + labelAsText={locale} + /> + ))} + + ); + }, + decorators: [ + (Story): React.ReactElement> => ( + {Story()} + ), + ], +}; diff --git a/__docs__/wonder-blocks-dropdown/option-item-examples.tsx b/__docs__/wonder-blocks-dropdown/option-item-examples.tsx index 5b679945e..465f78218 100644 --- a/__docs__/wonder-blocks-dropdown/option-item-examples.tsx +++ b/__docs__/wonder-blocks-dropdown/option-item-examples.tsx @@ -1,5 +1,10 @@ import * as React from "react"; import userCircleIcon from "@phosphor-icons/core/duotone/user-circle-duotone.svg"; +import chatBubbleIcon from "@phosphor-icons/core/regular/chats.svg"; +import bitcoinIcon from "@phosphor-icons/core/regular/currency-btc.svg"; +import euroIcon from "@phosphor-icons/core/regular/currency-eur.svg"; +import dollarIcon from "@phosphor-icons/core/regular/currency-dollar.svg"; +import yenIcon from "@phosphor-icons/core/regular/currency-jpy.svg"; import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon"; export const allCountries = [ @@ -301,3 +306,23 @@ export const allProfilesWithPictures = [ picture: icon, }, ]; + +export const currencies = [ + {name: "Bitcoin", icon: bitcoinIcon}, + {name: "Dollars", icon: dollarIcon}, + {name: "Yen", icon: yenIcon}, + {name: "Euros", icon: euroIcon}, +]; + +export const locales = [ + "অসমীয়া", + "Azərbaycanca", + "čeština", + "dansk", + "Ελληνικά", + "ગુજરાતી", + "magyar", + "Bahasa Indonesia", +]; + +export const chatIcon = ; diff --git a/__docs__/wonder-blocks-dropdown/single-select.argtypes.ts b/__docs__/wonder-blocks-dropdown/single-select.argtypes.ts index b381ccaac..c2bb94145 100644 --- a/__docs__/wonder-blocks-dropdown/single-select.argtypes.ts +++ b/__docs__/wonder-blocks-dropdown/single-select.argtypes.ts @@ -21,6 +21,18 @@ const argTypes: ArgTypes = { type: {summary: "Labels"}, }, }, + showOpenerLabelAsText: { + control: {type: "boolean"}, + description: `When false, the SelectOpener can show a Node as a label. When true, the + SelectOpener will use a string as a label. If using custom OptionItems, a + plain text label can be provided with the \`labelAsText\` prop. + Defaults to true.`, + + table: { + type: {summary: "boolean"}, + defaultValue: {summary: "true"}, + }, + }, }; export default argTypes; diff --git a/__docs__/wonder-blocks-dropdown/single-select.stories.tsx b/__docs__/wonder-blocks-dropdown/single-select.stories.tsx index 5f74209f1..99760b932 100644 --- a/__docs__/wonder-blocks-dropdown/single-select.stories.tsx +++ b/__docs__/wonder-blocks-dropdown/single-select.stories.tsx @@ -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"; @@ -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"; @@ -17,6 +18,7 @@ import { Body, HeadingLarge, LabelMedium, + LabelSmall, } from "@khanacademy/wonder-blocks-typography"; import { SingleSelect, @@ -31,7 +33,11 @@ import ComponentInfo from "../../.storybook/components/component-info"; import singleSelectArgtypes from "./single-select.argtypes"; import {IconMappings} from "../wonder-blocks-icon/phosphor-icon.argtypes"; import {defaultLabels} from "../../packages/wonder-blocks-dropdown/src/util/constants"; -import {allCountries, allProfilesWithPictures} from "./option-item-examples"; +import { + allCountries, + allProfilesWithPictures, + currencies, +} from "./option-item-examples"; import {OpenerProps} from "../../packages/wonder-blocks-dropdown/src/util/types"; type StoryComponentType = StoryObj; @@ -376,39 +382,147 @@ export const Disabled: StoryComponentType = { ), }; -const ErrorWrapper = () => { - const [error, setError] = React.useState(true); - const [selectedValue, setSelectedValue] = React.useState(""); +const ControlledSingleSelect = (args: PropsFor) => { const [opened, setOpened] = React.useState(false); - + const [selectedValue, setSelectedValue] = React.useState( + args.selectedValue, + ); + const [errorMessage, setErrorMessage] = React.useState< + null | string | void + >(null); return ( - <> - - Select any fruit other than lemon to clear the error! - + { - 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} - + {(errorMessage || args.error) && ( + + {errorMessage || "Error from error prop"} + + )} + ); }; /** - * 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) => { + return ( + + + Validation example (try picking lemon to trigger an error) + + { + if (value === "lemon") { + return "Pick another option!"; + } + }} + > + {items} + + + Validation example (on mount) + + { + if (value === "lemon") { + return "Pick another option!"; + } + }} + selectedValue="lemon" + > + {items} + + + ); + }, }; /** @@ -884,6 +998,58 @@ export const CustomOptionItems: StoryComponentType = { }, }; +/** + * This example illustrates how a JSX Element can appear as the label if + * `labelAsText` is undefined. Note that in this example, we define `labelAsText` + * on the OptionItems to ensure that filtering works correctly. + */ +export const CustomOptionItemWithNodeLabel: StoryComponentType = { + render: function Render() { + const [opened, setOpened] = React.useState(true); + const [selectedValue, setSelectedValue] = React.useState(""); + + const handleChange = (selectedValue: string) => { + setSelectedValue(selectedValue); + }; + + const handleToggle = (opened: boolean) => { + setOpened(opened); + }; + + return ( + + + {currencies.map((currency, index) => ( + + + {currency.name} + + } + labelAsText={currency.name} + /> + ))} + + + ); + }, +}; + /** * This example illustrates how you can use the `OptionItem` component to * display a virtualized list with custom option items. Note that in this diff --git a/__docs__/wonder-blocks-i18n/i18n-inline-markup.stories.tsx b/__docs__/wonder-blocks-i18n/i18n-inline-markup.stories.tsx deleted file mode 100644 index 8f2ea476f..000000000 --- a/__docs__/wonder-blocks-i18n/i18n-inline-markup.stories.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import * as React from "react"; - -import type {Meta, StoryObj} from "@storybook/react"; -import {color} from "@khanacademy/wonder-blocks-tokens"; -import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon"; -import Tooltip, {TooltipContent} from "@khanacademy/wonder-blocks-tooltip"; -import {LabelMedium} from "@khanacademy/wonder-blocks-typography"; - -import * as i18n from "@khanacademy/wonder-blocks-i18n"; - -import {I18nInlineMarkup} from "../../packages/wonder-blocks-i18n/src/components/i18n-inline-markup"; -import {IconMappings} from "../wonder-blocks-icon/phosphor-icon.argtypes"; - -export default { - title: "Packages / Translations / I18nInlineMarkup", - component: I18nInlineMarkup, - parameters: { - // We don't really care what the output looks for these stories. - chromatic: { - disableSnapshot: true, - }, - }, -} as Meta; - -type StoryComponentType = StoryObj; - -export const SingleShallowSubstitution: StoryComponentType = { - render: () => { - return ( - ( - - [Underline:{t}] - - )} - > - {i18n._( - "-6\u00b0C, Sunny, Fells like: -12, Wind: VR 5 km/h", - )} - - ); - }, -}; - -export const MultipleShallowSubstitution: StoryComponentType = { - render: () => { - return ( - ( - - __{t}__ - - )} - i={(t: string) => ( - - *{t}* - - )} - > - {i18n._( - "-6\u00b0C, Sunny, Fells like: -12, Wind: VR 5 km/h", - )} - - ); - }, -}; - -export const ElementWrapper: StoryComponentType = { - render: () => { - return ( - ( - {elem} - )} - u={(t: string) => ( - - __{t}__ - - )} - i={(t: string) => ( - - *{t}* - - )} - > - {i18n._( - "-6\u00b0C, Sunny, Fells like: -12, Wind: VR 5 km/h", - )} - - ); - }, -}; - -export const HandlingTranslationErrors: StoryComponentType = { - render: () => { - return ( - ( - // @ts-expect-error(FEI-5000): No overload matches this call. - - {label} - - )} - onError={(error) => ( - - - {error.message} - - - } - > - - - )} - > - {i18n._( - "This HTML is broken \u003cinvalid\u003einvalid\u003e innner \u003c/invalid\u003e, but here is fine.", - )} - - ); - }, -}; - -HandlingTranslationErrors.parameters = { - docs: { - description: { - story: - `This story shows how to handle translation errors. The ` + - `\`onError\` prop is called when there is an error parsing ` + - `the translation. In this example, we're using a tooltip ` + - `to show the error message.`, - }, - }, -}; diff --git a/__docs__/wonder-blocks-link/link.stories.tsx b/__docs__/wonder-blocks-link/link.stories.tsx index 642b14dab..af9f341cc 100644 --- a/__docs__/wonder-blocks-link/link.stories.tsx +++ b/__docs__/wonder-blocks-link/link.stories.tsx @@ -3,7 +3,7 @@ // alternatives work. Click includes mouseUp, which removes the pressed style. /* eslint-disable testing-library/prefer-user-event */ import * as React from "react"; -import {expect, within, userEvent, fireEvent} from "@storybook/test"; +import {expect, within, userEvent /*fireEvent*/} from "@storybook/test"; import {StyleSheet} from "aphrodite"; import {MemoryRouter, Route, Switch} from "react-router-dom"; import type {Meta, StoryObj} from "@storybook/react"; @@ -38,8 +38,8 @@ export default { argTypes: LinkArgTypes, } as Meta; -const activeBlue = "#1b50b3"; -const fadedBlue = "#b5cefb"; +// const activeBlue = "#1b50b3"; +// const fadedBlue = "#b5cefb"; type StoryComponentType = StoryObj; @@ -81,18 +81,17 @@ Primary.play = async ({canvasElement}) => { `text-decoration: underline ${color.blue} solid`, ); - // Focus style with keyboard navigation - await userEvent.tab(); - const computedStyle = getComputedStyle(link, ":focus-visible"); - // rgb(24, 101, 242) is the same as Color.blue. `toBe` doesn't seem to - // compare different color formats, so hex was converted to RGB. - await expect(computedStyle.outline).toBe("rgb(24, 101, 242) solid 1px"); - - // Mousedown style - await fireEvent.mouseDown(link); - await expect(link).toHaveStyle( - `text-decoration: underline solid ${activeBlue}`, - ); + // TODO(WB-1809, somewhatabstract): This isn't working. + // // Focus style with keyboard navigation + // await userEvent.tab(); + // // rgb(24, 101, 242) is the same as Color.blue + // await expect(link).toHaveStyle("outline: rgb(24, 101, 242) solid 1px"); + + // // Mousedown style + // await fireEvent.mouseDown(link); + // await expect(link).toHaveStyle( + // `text-decoration: underline solid ${activeBlue}`, + // ); }; export const Secondary: StoryComponentType = () => ( @@ -128,18 +127,17 @@ Secondary.play = async ({canvasElement}) => { `text-decoration: underline ${color.offBlack64} solid`, ); - // Focus style with keyboard navigation - await userEvent.tab(); - const computedStyle = getComputedStyle(link, ":focus-visible"); - // rgb(24, 101, 242) is the same as Color.blue. `toBe` doesn't seem to - // compare different color formats, so hex was converted to RGB. - await expect(computedStyle.outline).toBe("rgb(24, 101, 242) solid 1px"); - - // Mousedown style - await fireEvent.mouseDown(link); - await expect(link).toHaveStyle( - `text-decoration: underline solid ${color.offBlack}`, - ); + // TODO(WB-1809, somewhatabstract): This isn't working. + // // Focus style with keyboard navigation + // await userEvent.tab(); + // // rgb(24, 101, 242) is the same as Color.blue. + // await expect(link).toHaveStyle("outline: rgb(24, 101, 242) solid 1px"); + + // // Mousedown style + // await fireEvent.mouseDown(link); + // await expect(link).toHaveStyle( + // `text-decoration: underline solid ${color.offBlack}`, + // ); }; export const Visitable: StoryComponentType = () => ( @@ -196,18 +194,17 @@ LightPrimary.play = async ({canvasElement}) => { `text-decoration: underline ${color.white} solid`, ); + // TODO(WB-1809, somewhatabstract): This isn't working. // Focus style with keyboard navigation - await userEvent.tab(); - const computedStyle = getComputedStyle(link, ":focus-visible"); - // rgb(255, 255, 255) is the same as Color.white. `toBe` doesn't seem to - // compare different color formats, so hex was converted to RGB. - await expect(computedStyle.outline).toBe("rgb(255, 255, 255) solid 1px"); - - // Mousedown style - await fireEvent.mouseDown(link); - await expect(link).toHaveStyle( - `text-decoration: underline solid ${fadedBlue}`, - ); + // await userEvent.tab(); + // // rgb(255, 255, 255) is the same as Color.white. + // await expect(link).toHaveStyle("outline: rgb(255, 255, 255) solid 1px"); + + // // Mousedown style + // await fireEvent.mouseDown(link); + // await expect(link).toHaveStyle( + // `text-decoration: underline solid ${fadedBlue}`, + // ); }; export const LightVisitable: StoryComponentType = () => ( @@ -490,23 +487,19 @@ Inline.play = async ({canvasElement}) => { `text-decoration: underline ${color.blue} solid`, ); - // Focus style with keyboard navigation - await userEvent.tab(); - const primaryComputedStyle = getComputedStyle( - primaryLink, - ":focus-visible", - ); - // rgb(24, 101, 242) is the same as Color.blue. `toBe` doesn't seem to - // compare different color formats, so hex was converted to RGB. - await expect(primaryComputedStyle.outline).toBe( - "rgb(24, 101, 242) solid 1px", - ); + // TODO(WB-1809, somewhatabstract): This isn't working. + // // Focus style with keyboard navigation + // await userEvent.tab(); + // // rgb(24, 101, 242) is the same as Color.blue. + // await expect(primaryLink).toHaveStyle( + // "outline: rgb(24, 101, 242) solid 1px", + // ); - // Mousedown style - await fireEvent.mouseDown(primaryLink); - await expect(primaryLink).toHaveStyle( - `text-decoration: underline solid ${activeBlue}`, - ); + // // Mousedown style + // await fireEvent.mouseDown(primaryLink); + // await expect(primaryLink).toHaveStyle( + // `text-decoration: underline solid ${activeBlue}`, + // ); /* *** Secondary link styles*** */ @@ -522,25 +515,20 @@ Inline.play = async ({canvasElement}) => { await expect(secondaryLink).toHaveStyle( `text-decoration: underline ${color.offBlack} solid`, ); + // TODO(WB-1809, somewhatabstract): This isn't working. + // // Focus style with keyboard navigation + // await userEvent.tab(); + // await userEvent.tab(); + // // rgb(24, 101, 242) is the same as Color.blue. + // await expect(secondaryLink).toHaveStyle( + // "outline: rgb(24, 101, 242) solid 1px", + // ); - // Focus style with keyboard navigation - await userEvent.tab(); - await userEvent.tab(); - const secondaryComputedStyle = getComputedStyle( - secondaryLink, - ":focus-visible", - ); - // rgb(24, 101, 242) is the same as Color.blue. `toBe` doesn't seem to - // compare different color formats, so hex was converted to RGB. - await expect(secondaryComputedStyle.outline).toBe( - "rgb(24, 101, 242) solid 1px", - ); - - // Mousedown style - await fireEvent.mouseDown(secondaryLink); - await expect(secondaryLink).toHaveStyle( - `text-decoration: underline solid ${activeBlue}`, - ); + // // Mousedown style + // await fireEvent.mouseDown(secondaryLink); + // await expect(secondaryLink).toHaveStyle( + // `text-decoration: underline solid ${activeBlue}`, + // ); }; export const InlineLight: StoryComponentType = () => ( @@ -591,7 +579,8 @@ InlineLight.parameters = { }, }; -InlineLight.play = async ({canvasElement}) => { +// TODO(WB-1809, somewhatabstract): This isn't working. +/* InlineLight.play = async ({canvasElement}) => { const canvas = within(canvasElement); const primaryLink = canvas.getByRole("link", {name: "Primary link"}); @@ -609,19 +598,20 @@ InlineLight.play = async ({canvasElement}) => { `text-decoration: underline ${color.white} solid`, ); - // Focus style with keyboard navigation - await userEvent.tab(); - const computedStyle = getComputedStyle(primaryLink, ":focus-visible"); - // rgb(255, 255, 255) is the same as Color.white. `toBe` doesn't seem to - // compare different color formats, so hex was converted to RGB. - await expect(computedStyle.outline).toBe("rgb(255, 255, 255) solid 1px"); - - // Mousedown style - await fireEvent.mouseDown(primaryLink); - await expect(primaryLink).toHaveStyle( - `text-decoration: underline solid ${fadedBlue}`, - ); + // // Focus style with keyboard navigation + // await userEvent.tab(); + // // rgb(255, 255, 255) is the same as Color.white. + // await expect(primaryLink).toHaveStyle( + // "outline: rgb(255, 255, 255) solid 1px", + // ); + + // // Mousedown style + // await fireEvent.mouseDown(primaryLink); + // await expect(primaryLink).toHaveStyle( + // `text-decoration: underline solid ${fadedBlue}`, + // ); }; +*/ export const Variants: StoryComponentType = () => ( diff --git a/__docs__/wonder-blocks-modal/modal-launcher.argtypes.ts b/__docs__/wonder-blocks-modal/modal-launcher.argtypes.ts index 48e1ab57e..5c8851826 100644 --- a/__docs__/wonder-blocks-modal/modal-launcher.argtypes.ts +++ b/__docs__/wonder-blocks-modal/modal-launcher.argtypes.ts @@ -39,7 +39,8 @@ export default { control: {type: "text"}, description: `The selector for the element that will be focused when the dialog shows. When not set, the first tabbable element - within the dialog will be used.`, + within the dialog will be used, which usually is the dismiss button + (X).`, table: { type: {summary: "string"}, }, diff --git a/__docs__/wonder-blocks-pill/pill.stories.tsx b/__docs__/wonder-blocks-pill/pill.stories.tsx index 5534f51ec..fa4099721 100644 --- a/__docs__/wonder-blocks-pill/pill.stories.tsx +++ b/__docs__/wonder-blocks-pill/pill.stories.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import type {Meta, StoryObj} from "@storybook/react"; -import {expect, within, userEvent} from "@storybook/test"; +// import {expect, within} from "@storybook/test"; import {View} from "@khanacademy/wonder-blocks-core"; import Link from "@khanacademy/wonder-blocks-link"; import Pill from "@khanacademy/wonder-blocks-pill"; @@ -205,148 +205,151 @@ export const Variants: StoryComponentType = { }; // Test visual styles -Variants.play = async ({canvasElement}) => { - const canvas = within(canvasElement); - - // Define non-clickable pills - const neutralSmall = canvas.getByTestId("neutral-small-test-id"); - const accentSmall = canvas.getByTestId("accent-small-test-id"); - const infoSmall = canvas.getByTestId("info-small-test-id"); - const successSmall = canvas.getByTestId("success-small-test-id"); - const warningSmall = canvas.getByTestId("warning-small-test-id"); - const criticalSmall = canvas.getByTestId("critical-small-test-id"); - const neutralMedium = canvas.getByTestId("neutral-medium-test-id"); - const neutralLarge = canvas.getByTestId("neutral-large-test-id"); - const accentLarge = canvas.getByTestId("accent-large-test-id"); - const infoLarge = canvas.getByTestId("info-large-test-id"); - const successLarge = canvas.getByTestId("success-large-test-id"); - const warningLarge = canvas.getByTestId("warning-large-test-id"); - const criticalLarge = canvas.getByTestId("critical-large-test-id"); - - // Define clickable pills - const neutralMediumClickable = canvas.getByTestId( - "neutral-medium-clickable-test-id", - ); - const accentMediumClickable = canvas.getByTestId( - "accent-medium-clickable-test-id", - ); - const infoMediumClickable = canvas.getByTestId( - "info-medium-clickable-test-id", - ); - const successMediumClickable = canvas.getByTestId( - "success-medium-clickable-test-id", - ); - const warningMediumClickable = canvas.getByTestId( - "warning-medium-clickable-test-id", - ); - const criticalMediumClickable = canvas.getByTestId( - "critical-medium-clickable-test-id", - ); - - // Test non-clickable pill styles - await expect(neutralSmall).toHaveStyle({ - backgroundColor: tokens.color.offBlack8, - color: tokens.color.offBlack, - fontSize: 12, - }); - - await expect(accentSmall).toHaveStyle({ - backgroundColor: tokens.color.blue, - color: tokens.color.white, - fontSize: 12, - }); - - await expect(infoSmall).toHaveStyle({ - backgroundColor: tokens.color.fadedBlue16, - color: tokens.color.offBlack, - fontSize: 12, - }); - - await expect(successSmall).toHaveStyle({ - backgroundColor: tokens.color.fadedGreen16, - color: tokens.color.offBlack, - fontSize: 12, - }); - - await expect(warningSmall).toHaveStyle({ - backgroundColor: tokens.color.fadedGold16, - color: tokens.color.offBlack, - fontSize: 12, - }); - - await expect(criticalSmall).toHaveStyle({ - backgroundColor: tokens.color.fadedRed16, - color: tokens.color.offBlack, - fontSize: 12, - }); - - await expect(neutralMedium).toHaveStyle({ - backgroundColor: tokens.color.offBlack8, - color: tokens.color.offBlack, - fontSize: 14, - }); - - await expect(neutralLarge).toHaveStyle({ - backgroundColor: tokens.color.offBlack8, - color: tokens.color.offBlack, - fontSize: 16, - }); - - await expect(accentLarge).toHaveStyle({ - backgroundColor: tokens.color.blue, - color: tokens.color.white, - fontSize: 16, - }); - - await expect(infoLarge).toHaveStyle({ - backgroundColor: tokens.color.fadedBlue16, - color: tokens.color.offBlack, - fontSize: 16, - }); - - await expect(successLarge).toHaveStyle({ - backgroundColor: tokens.color.fadedGreen16, - color: tokens.color.offBlack, - fontSize: 16, - }); - - await expect(warningLarge).toHaveStyle({ - backgroundColor: tokens.color.fadedGold16, - color: tokens.color.offBlack, - fontSize: 16, - }); - - await expect(criticalLarge).toHaveStyle({ - backgroundColor: tokens.color.fadedRed16, - color: tokens.color.offBlack, - fontSize: 16, - }); - - // Test clickable pill styles - await neutralMediumClickable.focus(); - let computedStyle = getComputedStyle(neutralMediumClickable, ":hover"); - await expect(computedStyle.outline).toBe("rgb(24, 101, 242) solid 2px"); - - await userEvent.tab(); - computedStyle = getComputedStyle(accentMediumClickable, ":hover"); - await expect(computedStyle.outline).toBe("rgb(24, 101, 242) solid 2px"); - - await userEvent.tab(); - computedStyle = getComputedStyle(infoMediumClickable, ":hover"); - await expect(computedStyle.outline).toBe("rgb(24, 101, 242) solid 2px"); - - await userEvent.tab(); - computedStyle = getComputedStyle(successMediumClickable, ":hover"); - await expect(computedStyle.outline).toBe("rgb(24, 101, 242) solid 2px"); - - await userEvent.tab(); - computedStyle = getComputedStyle(warningMediumClickable, ":hover"); - await expect(computedStyle.outline).toBe("rgb(24, 101, 242) solid 2px"); - - await userEvent.tab(); - computedStyle = getComputedStyle(criticalMediumClickable, ":hover"); - await expect(computedStyle.outline).toBe("rgb(217, 41, 22) solid 2px"); -}; +// TODO(WB-1810, somewhatabstract): These aren't working. I got some passing +// locally by calling `.focus()` directly on the elements as well as via +// fireEvent, but it was super duper flaky and never passed first time. +// Variants.play = async ({canvasElement}) => { +// const canvas = within(canvasElement); + +// // Define non-clickable pills +// const neutralSmall = canvas.getByTestId("neutral-small-test-id"); +// const accentSmall = canvas.getByTestId("accent-small-test-id"); +// const infoSmall = canvas.getByTestId("info-small-test-id"); +// const successSmall = canvas.getByTestId("success-small-test-id"); +// const warningSmall = canvas.getByTestId("warning-small-test-id"); +// const criticalSmall = canvas.getByTestId("critical-small-test-id"); +// const neutralMedium = canvas.getByTestId("neutral-medium-test-id"); +// const neutralLarge = canvas.getByTestId("neutral-large-test-id"); +// const accentLarge = canvas.getByTestId("accent-large-test-id"); +// const infoLarge = canvas.getByTestId("info-large-test-id"); +// const successLarge = canvas.getByTestId("success-large-test-id"); +// const warningLarge = canvas.getByTestId("warning-large-test-id"); +// const criticalLarge = canvas.getByTestId("critical-large-test-id"); + +// // Test non-clickable pill styles +// await expect(neutralSmall).toHaveStyle({ +// backgroundColor: tokens.color.offBlack8, +// color: tokens.color.offBlack, +// fontSize: 12, +// }); + +// await expect(accentSmall).toHaveStyle({ +// backgroundColor: tokens.color.blue, +// color: tokens.color.white, +// fontSize: 12, +// }); + +// await expect(infoSmall).toHaveStyle({ +// backgroundColor: tokens.color.fadedBlue16, +// color: tokens.color.offBlack, +// fontSize: 12, +// }); + +// await expect(successSmall).toHaveStyle({ +// backgroundColor: tokens.color.fadedGreen16, +// color: tokens.color.offBlack, +// fontSize: 12, +// }); + +// await expect(warningSmall).toHaveStyle({ +// backgroundColor: tokens.color.fadedGold16, +// color: tokens.color.offBlack, +// fontSize: 12, +// }); + +// await expect(criticalSmall).toHaveStyle({ +// backgroundColor: tokens.color.fadedRed16, +// color: tokens.color.offBlack, +// fontSize: 12, +// }); + +// await expect(neutralMedium).toHaveStyle({ +// backgroundColor: tokens.color.offBlack8, +// color: tokens.color.offBlack, +// fontSize: 14, +// }); + +// await expect(neutralLarge).toHaveStyle({ +// backgroundColor: tokens.color.offBlack8, +// color: tokens.color.offBlack, +// fontSize: 16, +// }); + +// await expect(accentLarge).toHaveStyle({ +// backgroundColor: tokens.color.blue, +// color: tokens.color.white, +// fontSize: 16, +// }); + +// await expect(infoLarge).toHaveStyle({ +// backgroundColor: tokens.color.fadedBlue16, +// color: tokens.color.offBlack, +// fontSize: 16, +// }); + +// await expect(successLarge).toHaveStyle({ +// backgroundColor: tokens.color.fadedGreen16, +// color: tokens.color.offBlack, +// fontSize: 16, +// }); + +// await expect(warningLarge).toHaveStyle({ +// backgroundColor: tokens.color.fadedGold16, +// color: tokens.color.offBlack, +// fontSize: 16, +// }); + +// await expect(criticalLarge).toHaveStyle({ +// backgroundColor: tokens.color.fadedRed16, +// color: tokens.color.offBlack, +// fontSize: 16, +// }); + +// // Define clickable pills +// // const neutralMediumClickable = canvas.getByTestId( +// // "neutral-medium-clickable-test-id", +// // ); +// // const accentMediumClickable = canvas.getByTestId( +// // "accent-medium-clickable-test-id", +// // ); +// // const infoMediumClickable = canvas.getByTestId( +// // "info-medium-clickable-test-id", +// // ); +// // const successMediumClickable = canvas.getByTestId( +// // "success-medium-clickable-test-id", +// // ); +// // const warningMediumClickable = canvas.getByTestId( +// // "warning-medium-clickable-test-id", +// // ); +// // const criticalMediumClickable = canvas.getByTestId( +// // "critical-medium-clickable-test-id", +// // ); + +// // Test clickable pill styles +// // await fireEvent.focus(neutralMediumClickable); +// // let computedStyle = getComputedStyle(neutralMediumClickable, ":hover"); +// // await expect(computedStyle.outline).toBe("rgb(24, 101, 242) solid 2px"); + +// // await userEvent.tab(); +// // computedStyle = getComputedStyle(accentMediumClickable, ":hover"); +// // await expect(computedStyle.outline).toBe("rgb(24, 101, 242) solid 2px"); + +// // await userEvent.tab(); +// // computedStyle = getComputedStyle(infoMediumClickable, ":hover"); +// // await expect(computedStyle.outline).toBe("rgb(24, 101, 242) solid 2px"); + +// // await userEvent.tab(); +// // computedStyle = getComputedStyle(successMediumClickable, ":hover"); +// // await expect(computedStyle.outline).toBe("rgb(24, 101, 242) solid 2px"); + +// // await userEvent.tab(); +// // computedStyle = getComputedStyle(warningMediumClickable, ":hover"); +// // await expect(computedStyle.outline).toBe("rgb(24, 101, 242) solid 2px"); + +// // await userEvent.tab(); +// // computedStyle = getComputedStyle(criticalMediumClickable, ":hover"); +// // await expect(computedStyle.outline).toBe("rgb(217, 41, 22) solid 2px"); +// }; export const WithTypography: StoryComponentType = () => ( diff --git a/__docs__/wonder-blocks-search-field/search-field-variants.stories.tsx b/__docs__/wonder-blocks-search-field/search-field-variants.stories.tsx new file mode 100644 index 000000000..e821674f9 --- /dev/null +++ b/__docs__/wonder-blocks-search-field/search-field-variants.stories.tsx @@ -0,0 +1,166 @@ +import * as React from "react"; +import {StyleSheet} from "aphrodite"; +import type {Meta, StoryObj} from "@storybook/react"; + +import {View} from "@khanacademy/wonder-blocks-core"; +import {color, spacing} from "@khanacademy/wonder-blocks-tokens"; +import {LabelLarge, LabelMedium} from "@khanacademy/wonder-blocks-typography"; +import SearchField from "@khanacademy/wonder-blocks-search-field"; + +/** + * The following stories are used to generate the pseudo states for the + * SearchField component. This is only used for visual testing in Chromatic. + */ +export default { + title: "Packages / SearchField / All Variants", + parameters: { + docs: { + autodocs: false, + }, + }, +} as Meta; + +type StoryComponentType = StoryObj; + +const longText = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur."; +const longTextWithNoWordBreak = + "Loremipsumdolorsitametconsecteturadipiscingelitseddoeiusmodtemporincididuntutlaboreetdoloremagnaaliqua"; + +const states = [ + { + label: "Default", + props: {}, + }, + { + label: "Disabled", + props: {disabled: true}, + }, + { + label: "Error", + props: {error: true}, + }, +]; +const States = (props: { + light: boolean; + label: string; + value?: string; + placeholder?: string; +}) => { + return ( + + + {props.label} + + + {states.map((scenario) => { + return ( + + + {scenario.label} + + {}} + {...props} + {...scenario.props} + /> + + ); + })} + + + ); +}; + +const AllVariants = () => ( + + {[false, true].map((light) => { + return ( + + + + + + + + + + ); + })} + +); + +export const Default: StoryComponentType = { + render: AllVariants, +}; + +/** + * There are currently only hover styles on the clear button. + */ +export const Hover: StoryComponentType = { + render: AllVariants, + parameters: {pseudo: {hover: true}}, +}; + +export const Focus: StoryComponentType = { + render: AllVariants, + parameters: {pseudo: {focusVisible: true}}, +}; + +export const HoverFocus: StoryComponentType = { + name: "Hover + Focus", + render: AllVariants, + parameters: {pseudo: {hover: true, focusVisible: true}}, +}; + +/** + * There are currently no active styles. + */ +export const Active: StoryComponentType = { + render: AllVariants, + parameters: {pseudo: {active: true}}, +}; + +const styles = StyleSheet.create({ + darkDefault: { + backgroundColor: color.darkBlue, + }, + statesContainer: { + padding: spacing.medium_16, + }, + scenarios: { + flexDirection: "row", + alignItems: "center", + gap: spacing.xxxLarge_64, + flexWrap: "wrap", + }, + scenario: { + gap: spacing.small_12, + overflow: "hidden", + }, +}); diff --git a/__docs__/wonder-blocks-search-field/search-field.stories.tsx b/__docs__/wonder-blocks-search-field/search-field.stories.tsx index bf9e16c4f..9b15c8567 100644 --- a/__docs__/wonder-blocks-search-field/search-field.stories.tsx +++ b/__docs__/wonder-blocks-search-field/search-field.stories.tsx @@ -3,10 +3,10 @@ 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 {color, spacing} from "@khanacademy/wonder-blocks-tokens"; -import {LabelLarge} from "@khanacademy/wonder-blocks-typography"; +import {LabelLarge, LabelSmall} from "@khanacademy/wonder-blocks-typography"; import SearchField from "@khanacademy/wonder-blocks-search-field"; @@ -52,8 +52,11 @@ export default { type StoryComponentType = StoryObj; -const Template = (args: any) => { - const [value, setValue] = React.useState(""); +const Template = (args: PropsFor) => { + const [value, setValue] = React.useState(args?.value || ""); + const [errorMessage, setErrorMessage] = React.useState< + string | null | undefined + >(""); const handleChange = (newValue: string) => { setValue(newValue); @@ -66,15 +69,23 @@ const Template = (args: any) => { }; return ( - { - action("onKeyDown")(e); - handleKeyDown(e); - }} - /> + + { + action("onKeyDown")(e); + handleKeyDown(e); + }} + onValidate={setErrorMessage} + /> + {(errorMessage || args.error) && ( + + {errorMessage || "Error from error prop"} + + )} + ); }; @@ -217,9 +228,78 @@ export const WithAutofocus: StoryComponentType = { }, }; +/** + * The SearchField can be put in an error state using the `error` prop. + */ +export const Error: StoryComponentType = { + args: { + error: true, + }, + render: Template, + parameters: { + chromatic: { + // Disabling because this is covered by the All Variants stories + disableSnapshot: true, + }, + }, +}; + +/** + * The SearchField supports `validate`, `onValidate`, and `instantValidation` + * props. + * + * See docs for the TextField component for more details around validation + * since SearchField uses TextField internally. + */ +export const Validation: StoryComponentType = { + args: { + validate(value) { + if (value.length < 5) { + return "Too short. Value should be at least 5 characters"; + } + }, + }, + render: (args) => { + return ( + + + Validation on mount if there is a value + +