From 982f680865ad1ffc5fb6a0743fdf68c3853a65dc Mon Sep 17 00:00:00 2001 From: Bea Esguerra Date: Mon, 26 Aug 2024 15:28:00 -0600 Subject: [PATCH] Update TextField styling for consistency with other components (focus styling) (#2302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: - Update `TextField` state styling so it is consistent with other components like `TextArea`, `SingleSelect`, `MultiSelect` (especially the focus styling) - The styling changes use CSS pseudo-classes now so that we can have visual regression tests for the UI states using Storybook's pseudo-states add on. - These styles align with the [Figma TextField component](https://www.figma.com/design/VbVu3h2BpBhH80niq101MHHE/%F0%9F%92%A0-Main-Components?node-id=13497-10015&t=QZlu8ecGmGU3QceA-4) - Removed unneeded focused state since we use pseudo classes now - Added variant stories for Chromatic tests Note: These styles are based on how TextArea was styled! Issue: WB-1746 ## Test plan: 1. Confirm the focus styling for TextField when (`?path=/docs/packages-form-textfield--docs`): - it is focused - it is focused on a dark background with the `light` prop set to `true` - it is in an error state and is focused - it is in an error state on a dark background with the `light` prop set to `true` 2. Confirm other states (error, disabled, default) 3. Review the new variant stories (`?path=/docs/packages-form-textfield-all-variants--docs`) Author: beaesguerra Reviewers: beaesguerra, jandrade Required Reviewers: Approved By: jandrade Checks: ✅ codecov/project, ✅ Chromatic - Get results on regular PRs (ubuntu-latest, 20.x), ✅ Test (ubuntu-latest, 20.x, 2/2), ✅ Test (ubuntu-latest, 20.x, 1/2), ✅ 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: https://github.com/Khan/wonder-blocks/pull/2302 --- .changeset/famous-walls-scream.md | 5 + .../text-field-variants.stories.tsx | 168 ++++++++++++++++++ .../__tests__/labeled-text-field.test.tsx | 23 --- .../src/components/text-area.tsx | 6 +- .../src/components/text-field.tsx | 126 ++++++++----- 5 files changed, 258 insertions(+), 70 deletions(-) create mode 100644 .changeset/famous-walls-scream.md create mode 100644 __docs__/wonder-blocks-form/text-field-variants.stories.tsx diff --git a/.changeset/famous-walls-scream.md b/.changeset/famous-walls-scream.md new file mode 100644 index 000000000..d80391005 --- /dev/null +++ b/.changeset/famous-walls-scream.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/wonder-blocks-form": patch +--- + +Update `TextField` state styling so that it is consistent with other components like `TextArea`, `SingleSelect`, `MultiSelect` (especially the focus styling). The styling also now uses CSS pseudo-classes for easier testing in Chromatic and debugging in browsers. diff --git a/__docs__/wonder-blocks-form/text-field-variants.stories.tsx b/__docs__/wonder-blocks-form/text-field-variants.stories.tsx new file mode 100644 index 000000000..e02809842 --- /dev/null +++ b/__docs__/wonder-blocks-form/text-field-variants.stories.tsx @@ -0,0 +1,168 @@ +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 {TextField} from "@khanacademy/wonder-blocks-form"; + +/** + * The following stories are used to generate the pseudo states for the + * TextField component. This is only used for visual testing in Chromatic. + * + * Note: Error state is not shown on initial render if the TextField value is empty. + */ +export default { + title: "Packages / Form / TextField / 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: {validate: () => "Error"}, + }, +]; +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 no hover styles. + */ +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: { + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: spacing.xxxLarge_64, + flexWrap: "wrap", + }, + scenario: { + gap: spacing.small_12, + }, +}); diff --git a/packages/wonder-blocks-form/src/components/__tests__/labeled-text-field.test.tsx b/packages/wonder-blocks-form/src/components/__tests__/labeled-text-field.test.tsx index e3986aac1..d475696df 100644 --- a/packages/wonder-blocks-form/src/components/__tests__/labeled-text-field.test.tsx +++ b/packages/wonder-blocks-form/src/components/__tests__/labeled-text-field.test.tsx @@ -3,7 +3,6 @@ import {render, screen, fireEvent} from "@testing-library/react"; import {userEvent} from "@testing-library/user-event"; import {StyleSheet} from "aphrodite"; -import {color} from "@khanacademy/wonder-blocks-tokens"; import LabeledTextField from "../labeled-text-field"; describe("LabeledTextField", () => { @@ -382,28 +381,6 @@ describe("LabeledTextField", () => { expect(input).toBeInTheDocument(); }); - it("light prop is passed to textfield", async () => { - // Arrange - - // Act - render( - {}} - light={true} - />, - ); - - const textField = await screen.findByRole("textbox"); - textField.focus(); - - // Assert - expect(textField).toHaveStyle({ - boxShadow: `0px 0px 0px 1px ${color.blue}, 0px 0px 0px 2px ${color.white}`, - }); - }); - it("style prop is passed to fieldheading", async () => { // Arrange const styles = StyleSheet.create({ diff --git a/packages/wonder-blocks-form/src/components/text-area.tsx b/packages/wonder-blocks-form/src/components/text-area.tsx index 5cb6be067..cdf6a8f8d 100644 --- a/packages/wonder-blocks-form/src/components/text-area.tsx +++ b/packages/wonder-blocks-form/src/components/text-area.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import {CSSProperties, Falsy, StyleSheet} from "aphrodite"; +import {StyleSheet} from "aphrodite"; import { AriaProps, @@ -256,7 +256,7 @@ const TextArea = React.forwardRef( } }); - const getStyles = (): (CSSProperties | Falsy)[] => { + const getStyles = (): StyleType => { // Base styles are the styles that apply regardless of light mode const baseStyles = [ styles.textarea, @@ -284,7 +284,7 @@ const TextArea = React.forwardRef( data-testid={testId} ref={ref} className={className} - style={[...getStyles(), style]} + style={[getStyles(), style]} value={value} onChange={handleChange} placeholder={placeholder} diff --git a/packages/wonder-blocks-form/src/components/text-field.tsx b/packages/wonder-blocks-form/src/components/text-field.tsx index f1b2bcb6b..147f804c5 100644 --- a/packages/wonder-blocks-form/src/components/text-field.tsx +++ b/packages/wonder-blocks-form/src/components/text-field.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import {StyleSheet} from "aphrodite"; import {IDProvider, addStyle} from "@khanacademy/wonder-blocks-core"; -import {color, spacing} from "@khanacademy/wonder-blocks-tokens"; +import {border, color, mix, spacing} from "@khanacademy/wonder-blocks-tokens"; import {styles as typographyStyles} from "@khanacademy/wonder-blocks-typography"; import type {StyleType, AriaProps} from "@khanacademy/wonder-blocks-core"; @@ -152,10 +152,6 @@ type State = { * Displayed when the validation fails. */ error: string | null | undefined; - /** - * The user focuses on this field. - */ - focused: boolean; }; /** @@ -178,7 +174,6 @@ class TextField extends React.Component { state: State = { error: null, - focused: false, }; componentDidMount() { @@ -222,22 +217,38 @@ class TextField extends React.Component { event, ) => { const {onFocus} = this.props; - this.setState({focused: true}, () => { - if (onFocus) { - onFocus(event); - } - }); + if (onFocus) { + onFocus(event); + } }; handleBlur: (event: React.FocusEvent) => unknown = ( event, ) => { const {onBlur} = this.props; - this.setState({focused: false}, () => { - if (onBlur) { - onBlur(event); - } - }); + if (onBlur) { + onBlur(event); + } + }; + + getStyles = (): StyleType => { + const {disabled, light} = this.props; + const {error} = this.state; + // Base styles are the styles that apply regardless of light mode + const baseStyles = [styles.input, typographyStyles.LabelMedium]; + const defaultStyles = [ + styles.default, + !disabled && styles.defaultFocus, + disabled && styles.disabled, + !!error && styles.error, + ]; + const lightStyles = [ + styles.light, + !disabled && styles.lightFocus, + disabled && styles.lightDisabled, + !!error && styles.lightError, + ]; + return [...baseStyles, ...(light ? lightStyles : defaultStyles)]; }; render(): React.ReactNode { @@ -249,7 +260,6 @@ class TextField extends React.Component { disabled, onKeyDown, placeholder, - light, style, testId, readOnly, @@ -259,6 +269,7 @@ class TextField extends React.Component { // The following props are being included here to avoid // passing them down to the otherProps spread /* eslint-disable @typescript-eslint/no-unused-vars */ + light, onFocus, onBlur, onValidate, @@ -274,24 +285,7 @@ class TextField extends React.Component { {(uniqueId) => (