diff --git a/docs/validations.mdx b/docs/validations.mdx index ca6f06b98f..7de70d71c7 100644 --- a/docs/validations.mdx +++ b/docs/validations.mdx @@ -64,6 +64,9 @@ Component props that are not supported if the opt-in flag is set to true are lab The legacy validation pattern is still available for use, but we recommend using the new validation pattern for a more consistent and user-friendly experience. This pattern uses tooltips to provide feedback to users about the state of their input. +The `Textbox` component is used below for documentation purposes, but it is recommend to use the new `TextInput` component instead which only uses the new validation pattern +by default. + #### States Each input component that supports validations accepts the following props - `error`, `warning` and `info`. @@ -117,7 +120,7 @@ For more information on how to use React Hook Form, please refer to the [React H import { useForm, SubmitHandler, Controller } from 'react-hook-form'; import Button from 'carbon-react/lib/components/button'; import Form from 'carbon-react/lib/components/form'; -import Textbox from 'carbon-react/lib/components/textbox'; +import TextInput from 'carbon-react/lib/components/textbox/__next__'; interface FormValues { name: string; @@ -146,7 +149,7 @@ const MyForm = () => { required: 'Name is required', }} render={({ field: { onChange, onBlur, value }, fieldState }) => ( - { Fill in all fields marked with - { required error="Error Message (Fix is required)" /> - { + const validation = error || warning; + const isStringValidation = typeof validation === "string"; + + return isStringValidation ? ( + + {validation} + + ) : null; +}; + +export default ValidationMessage; diff --git a/src/__internal__/validation-message/__next__/validation-message.style.ts b/src/__internal__/validation-message/__next__/validation-message.style.ts new file mode 100644 index 0000000000..bebca088cb --- /dev/null +++ b/src/__internal__/validation-message/__next__/validation-message.style.ts @@ -0,0 +1,38 @@ +import styled, { css } from "styled-components"; + +interface StyledValidationMessageProps { + isError?: boolean; + isDarkBackground?: boolean; + isLarge?: boolean; +} + +const StyledValidationMessage = styled.span` + ${({ isError, isLarge }) => { + return css` + display: flex; + align-items: center; + align-content: center; + align-self: stretch; + flex-wrap: wrap; + margin: 0px; + + margin: 0px; + + color: ${isError + ? "var(--input-validation-label-error)" + : "var(--input-validation-label-warn)"}; + + font-family: var(--fontFamiliesDefault); + + font-size: ${isLarge + ? "var(--global-font-static-body-regular-l)" + : "var(--global-font-static-body-regular-m)"}; + + font-style: normal; + font-weight: ${isError ? "500" : "400"}; + line-height: 150%; + `; + }} +`; + +export default StyledValidationMessage; diff --git a/src/__internal__/validation-message/__next__/validation-message.test.tsx b/src/__internal__/validation-message/__next__/validation-message.test.tsx new file mode 100644 index 0000000000..4788a396ab --- /dev/null +++ b/src/__internal__/validation-message/__next__/validation-message.test.tsx @@ -0,0 +1,118 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import ValidationMessage from "."; + +test("should not render when neither error nor warning is provided", () => { + render(); + expect(screen.queryByTestId("validation-message")).not.toBeInTheDocument(); +}); + +test("should render when error is a string", () => { + render(); + const validationMessage = screen.getByText("Error message"); + expect(validationMessage).toBeVisible(); +}); + +test("should render when warning is a string", () => { + render(); + const validationMessage = screen.getByText("Warning message"); + expect(validationMessage).toBeVisible(); +}); + +test("should render with id attribute", () => { + render(); + const validationMessage = screen.getByText("Error message"); + expect(validationMessage).toHaveAttribute("id", "validation-error-1"); +}); + +test("should support custom data-role attribute", () => { + render( + , + ); + const validationMessage = screen.getByTestId("custom-validation"); + expect(validationMessage).toBeVisible(); +}); + +test("should support data-element attribute", () => { + render( + , + ); + const validationMessage = screen.getByText("Error message"); + expect(validationMessage).toHaveAttribute("data-element", "validation-error"); +}); + +test("should apply error color when error is provided", () => { + render(); + const validationMessage = screen.getByText("Error message"); + + expect(validationMessage).toHaveStyle( + "color: var(--input-validation-label-error)", + ); +}); + +test("should apply error font-weight", () => { + render(); + const validationMessage = screen.getByText("Error message"); + expect(validationMessage).toHaveStyle("font-weight: 500"); +}); + +test("should apply warning color when warning is provided", () => { + render(); + const validationMessage = screen.getByText("Warning message"); + expect(validationMessage).toHaveStyle( + "color: var(--input-validation-label-warn)", + ); +}); + +test("should apply warning font-weight", () => { + render(); + const validationMessage = screen.getByText("Warning message"); + expect(validationMessage).toHaveStyle("font-weight: 400"); +}); + +test("should apply medium font size by default for error", () => { + render(); + const validationMessage = screen.getByText("Error message"); + expect(validationMessage).toHaveStyle( + "font-size: var(--global-font-static-body-regular-m)", + ); +}); + +test("should apply large font size when isLarge is true for error", () => { + render(); + const validationMessage = screen.getByText("Error message"); + expect(validationMessage).toHaveStyle( + "font-size: var(--global-font-static-body-regular-l)", + ); +}); + +test("should apply medium font size by default for warning", () => { + render(); + const validationMessage = screen.getByText("Warning message"); + expect(validationMessage).toHaveStyle( + "font-size: var(--global-font-static-body-regular-m)", + ); +}); + +test("should apply large font size when isLarge is true for warning", () => { + render(); + const validationMessage = screen.getByText("Warning message"); + expect(validationMessage).toHaveStyle( + "font-size: var(--global-font-static-body-regular-l)", + ); +}); + +test("should prioritize error over warning when both provided", () => { + render(); + expect(screen.getByText("Error message")).toBeVisible(); + expect(screen.queryByText("Warning message")).not.toBeInTheDocument(); +}); + +test("should apply error styling when both error and warning strings are provided", () => { + render(); + const validationMessage = screen.getByText("Error message"); + expect(validationMessage).toHaveStyle( + "color: var(--input-validation-label-error)", + ); + expect(validationMessage).toHaveStyle("font-weight: 500"); +}); diff --git a/src/components/textbox/__next__/__internal__/error-border/error-border.style.ts b/src/components/textbox/__next__/__internal__/error-border/error-border.style.ts new file mode 100644 index 0000000000..5e3fbce3d4 --- /dev/null +++ b/src/components/textbox/__next__/__internal__/error-border/error-border.style.ts @@ -0,0 +1,18 @@ +import styled, { css } from "styled-components"; + +const ErrorBorder = styled.span` + ${({ warning }: { warning: boolean }) => css` + width: 2px; + position: absolute; + left: -10px; + top: 0px; + bottom: 0px; + z-index: 6; + background-color: ${warning + ? "var(--input-validation-bar-warn)" + : "var(--input-validation-border-error)"}; + `} + transform: scaleX(1); +`; + +export default ErrorBorder; diff --git a/src/components/textbox/__next__/__internal__/error-border/error-border.test.tsx b/src/components/textbox/__next__/__internal__/error-border/error-border.test.tsx new file mode 100644 index 0000000000..b80a86a2bf --- /dev/null +++ b/src/components/textbox/__next__/__internal__/error-border/error-border.test.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import ErrorBorder from "./error-border.style"; + +test("when `warning` is true should render with warning background colour", () => { + render(); + + expect(screen.getByTestId("error-border")).toHaveStyleRule( + "background-color", + "var(--input-validation-bar-warn)", + ); +}); + +test("when `warning` is false should render with error background colour", () => { + render(); + + expect(screen.getByTestId("error-border")).toHaveStyleRule( + "background-color", + "var(--input-validation-border-error)", + ); +}); diff --git a/src/components/textbox/__next__/__internal__/hint-text/hint-text.component.tsx b/src/components/textbox/__next__/__internal__/hint-text/hint-text.component.tsx new file mode 100644 index 0000000000..744051c0b4 --- /dev/null +++ b/src/components/textbox/__next__/__internal__/hint-text/hint-text.component.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import StyledHintText from "./hint-text.style"; + +export interface HintTextProps { + /** Children elements */ + children?: React.ReactNode; + /** Sets the id for the component */ + id?: string; + /** If true, uses large font size */ + isLarge?: boolean; + /** If true, the hint text will display in disabled styling */ + disabled?: boolean; +} + +export const HintText = ({ + children, + id, + isLarge, + disabled, +}: HintTextProps) => { + return ( + + {children} + + ); +}; + +export default HintText; diff --git a/src/components/textbox/__next__/__internal__/hint-text/hint-text.style.ts b/src/components/textbox/__next__/__internal__/hint-text/hint-text.style.ts new file mode 100644 index 0000000000..e7ad51d262 --- /dev/null +++ b/src/components/textbox/__next__/__internal__/hint-text/hint-text.style.ts @@ -0,0 +1,26 @@ +import styled from "styled-components"; + +interface StyledHintTextProps { + isLarge?: boolean; + disabled?: boolean; +} + +const StyledHintText = styled.span` + color: ${({ disabled }) => + disabled + ? "var(--input-labelset-label-disabled)" + : "var(--input-labelset-label-alt)"}; + + font-family: var(--fontFamiliesDefault); + + font-size: ${({ isLarge }) => + isLarge + ? "var(--global-font-static-body-regular-l)" + : "var(--global-font-static-body-regular-m)"}; + + font-style: normal; + font-weight: 400; + line-height: 150%; +`; + +export default StyledHintText; diff --git a/src/components/textbox/__next__/__internal__/hint-text/hint-text.test.tsx b/src/components/textbox/__next__/__internal__/hint-text/hint-text.test.tsx new file mode 100644 index 0000000000..32b40d87d4 --- /dev/null +++ b/src/components/textbox/__next__/__internal__/hint-text/hint-text.test.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import HintText from "."; + +test("should render with children", () => { + render(This is a hint); + + expect(screen.getByText("This is a hint")).toBeVisible(); +}); + +test("should render with `id` prop", () => { + render(hint text); + + expect(screen.getByTestId("hint-text")).toHaveAttribute("id", "hint-1"); +}); + +test("should apply large font size when `isLarge` prop is true", () => { + render( + + hint text + , + ); + + const element = screen.getByTestId("hint-text"); + expect(element).toHaveStyleRule( + "font-size", + "var(--global-font-static-body-regular-l)", + ); +}); + +test("should apply medium font size when `isLarge` prop is false", () => { + render( + + hint text + , + ); + + const element = screen.getByTestId("hint-text"); + expect(element).toHaveStyleRule( + "font-size", + "var(--global-font-static-body-regular-m)", + ); +}); + +test("should apply disabled colour when `disabled` prop is true", () => { + render(hint text); + + const element = screen.getByTestId("hint-text"); + expect(element).toHaveStyleRule( + "color", + "var(--input-labelset-label-disabled)", + ); +}); + +test("should apply alt colour when `disabled` prop is false", () => { + render(hint text); + + const element = screen.getByTestId("hint-text"); + expect(element).toHaveStyleRule("color", "var(--input-labelset-label-alt)"); +}); diff --git a/src/components/textbox/__next__/__internal__/hint-text/index.ts b/src/components/textbox/__next__/__internal__/hint-text/index.ts new file mode 100644 index 0000000000..26af8ee135 --- /dev/null +++ b/src/components/textbox/__next__/__internal__/hint-text/index.ts @@ -0,0 +1 @@ +export { default } from "./hint-text.component"; diff --git a/src/components/textbox/__next__/__internal__/input/index.ts b/src/components/textbox/__next__/__internal__/input/index.ts new file mode 100644 index 0000000000..8262d9c833 --- /dev/null +++ b/src/components/textbox/__next__/__internal__/input/index.ts @@ -0,0 +1,2 @@ +export { default } from "./input.component"; +export type { InputProps } from "./input.component"; diff --git a/src/components/textbox/__next__/__internal__/input/input.component.tsx b/src/components/textbox/__next__/__internal__/input/input.component.tsx new file mode 100644 index 0000000000..e9df05656b --- /dev/null +++ b/src/components/textbox/__next__/__internal__/input/input.component.tsx @@ -0,0 +1,85 @@ +import React, { useEffect, useRef } from "react"; +import InputContainer from "./input.style"; +import Icon, { IconType } from "../../../../icon"; + +export interface InputProps + extends Omit< + React.InputHTMLAttributes, + "type" | "value" | "size" + > { + /** The ID of the input's description, is set along with hint text and error message. */ + "aria-describedby"?: string; + /** If true, the component will be disabled */ + disabled?: boolean; + /** HTML id attribute of the input */ + id?: string; + /** Name of the input */ + name?: string; + /** Specify a callback triggered on blur */ + onBlur?: (ev: React.FocusEvent) => void; + /** Specify a callback triggered on change */ + onChange: (ev: React.ChangeEvent) => void; + /** Specify a callback triggered on focus */ + onFocus?: (ev: React.FocusEvent) => void; + /** Placeholder string to be displayed in input */ + placeholder?: string; + /** If true, the component will be read-only */ + readOnly?: boolean; + /** Flag to configure component as mandatory */ + required?: boolean; + /** The value of the input */ + value: string | readonly string[] | number | undefined; + /** If true, the input will display error styling */ + error?: boolean; + /** The width of the input as a percentage (e.g., 50 for 50%) */ + inputWidth?: number; + /** Type of the icon that will be rendered next to the input */ + inputIcon?: IconType; + /** Size of the input */ + size?: "small" | "medium" | "large"; +} + +const Input = React.forwardRef( + ( + { + error, + inputWidth, + inputIcon, + disabled, + readOnly, + autoFocus, + size, + ...props + }, + ref, + ) => { + const localRef = useRef(null); + const inputRef = ref || localRef; + const stateProps = { disabled, readOnly }; + + useEffect(() => { + if (autoFocus && inputRef && "current" in inputRef) { + inputRef.current?.focus(); + } + }, [autoFocus, inputRef]); + + return ( + +
+ + {inputIcon && ( + + )} +
+
+ ); + }, +); + +export default Input; diff --git a/src/components/textbox/__next__/__internal__/input/input.style.ts b/src/components/textbox/__next__/__internal__/input/input.style.ts new file mode 100644 index 0000000000..c8c6459a73 --- /dev/null +++ b/src/components/textbox/__next__/__internal__/input/input.style.ts @@ -0,0 +1,123 @@ +import styled from "styled-components"; +import addFocusStyling from "../../../../../style/utils/add-focus-styling"; + +interface InputContainerProps { + error?: boolean; + inputWidth?: number; + disabled?: boolean; + readOnly?: boolean; + size?: "small" | "medium" | "large"; +} + +const InputContainer = styled.div` + display: flex; + ${({ inputWidth }) => inputWidth && `width: ${inputWidth}%;`} + + min-height: ${({ size = "medium" }) => { + switch (size) { + case "small": + return "var(--global-size-s)"; + case "large": + return "var(--global-size-l)"; + default: + return "var(--global-size-m)"; + } + }}; + + align-items: center; + align-self: stretch; + border-radius: var(--global-radius-action-m); + + border: ${({ error, disabled, readOnly }) => { + if (disabled) { + return `var(--global-borderwidth-xs) solid var(--input-typical-border-disabled)`; + } + if (readOnly) { + return `var(--global-borderwidth-xs) solid var(--input-typical-border-read-only)`; + } + return error + ? `var(--global-borderwidth-s) solid var(--input-validation-border-error)` + : `var(--global-borderwidth-xs) solid var(--input-typical-border-default)`; + }}; + + background: ${({ disabled, readOnly }) => { + if (disabled) { + return "var(--input-typical-bg-disabled)"; + } + if (readOnly) { + return "var(--input-typical-bg-read-only)"; + } + return "var(--input-typical-bg-default)"; + }}; + + &:focus-within { + ${addFocusStyling()} + z-index: 2; + } + + [data-role="input-text-container"] { + display: flex; + + padding: ${({ size = "medium" }) => { + switch (size) { + case "small": + return "0 var(--global-space-comp-s)"; + case "large": + return "0 var(--global-space-comp-l)"; + default: + return "0 var(--global-space-comp-m)"; + } + }}; + + align-items: center; + + gap: ${({ size = "medium" }) => { + switch (size) { + case "small": + return "var(--global-space-comp-xs)"; + case "large": + return "var(--global-space-comp-m)"; + default: + return "var(--global-space-comp-s)"; + } + }}; + + flex: 1 0 0; + align-self: stretch; + + input { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + flex: 1 0 0; + overflow: hidden; + border: none; + outline: none; + background: transparent; + + color: ${({ disabled, readOnly }) => { + if (disabled) { + return "var(--input-typical-txt-disabled)"; + } + if (readOnly) { + return "var(--input-typical-txt-read-only)"; + } + return "var(--input-typical-txt-default)"; + }}; + + text-overflow: ellipsis; + font-family: var(--fontFamiliesDefault); + + font-size: ${({ size }) => + size === "large" + ? "var(--global-font-static-body-regular-l)" + : "var(--global-font-static-body-regular-m)"}; + + font-style: normal; + font-weight: 400; + line-height: 150%; + } + } +`; + +export default InputContainer; diff --git a/src/components/textbox/__next__/__internal__/input/input.test.tsx b/src/components/textbox/__next__/__internal__/input/input.test.tsx new file mode 100644 index 0000000000..e74e4e9271 --- /dev/null +++ b/src/components/textbox/__next__/__internal__/input/input.test.tsx @@ -0,0 +1,251 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import Input from "."; + +test("should render text input element", () => { + render( {}} />); + + const input = screen.getByRole("textbox"); + expect(input).toBeVisible(); + expect(input).toHaveAttribute("type", "text"); +}); + +test("should render with `placeholder` prop", () => { + render( {}} />); + + const input = screen.getByPlaceholderText("Enter text"); + expect(input).toBeVisible(); +}); + +test("should render with `value` prop", () => { + render( {}} />); + + const input = screen.getByDisplayValue("test value"); + expect(input).toBeVisible(); +}); + +test("should render with `id` prop", () => { + render( {}} />); + + const input = screen.getByRole("textbox"); + expect(input).toHaveAttribute("id", "input-1"); +}); + +test("should render with `name` prop", () => { + render( {}} />); + + const input = screen.getByRole("textbox"); + expect(input).toHaveAttribute("name", "email"); +}); + +test("should render with `aria-describedby` prop", () => { + render( {}} />); + + const input = screen.getByRole("textbox"); + expect(input).toHaveAttribute("aria-describedby", "hint-text"); +}); + +test("should call `onChange` callback prop when value changes", async () => { + const handleChange = jest.fn(); + render(); + + const input = screen.getByRole("textbox"); + await userEvent.type(input, "a"); + expect(handleChange).toHaveBeenCalled(); +}); + +test("should call `onBlur` callback prop when input loses focus", async () => { + const handleBlur = jest.fn(); + render( {}} onBlur={handleBlur} />); + + const input = screen.getByRole("textbox"); + fireEvent.blur(input); + expect(handleBlur).toHaveBeenCalled(); +}); + +test("should call `onFocus` callback prop when input gains focus", async () => { + const handleFocus = jest.fn(); + render( {}} onFocus={handleFocus} />); + + const input = screen.getByRole("textbox"); + fireEvent.focus(input); + expect(handleFocus).toHaveBeenCalled(); +}); + +test("should render with `required` prop", () => { + render( {}} />); + + const input = screen.getByRole("textbox"); + expect(input).toBeRequired(); +}); + +test("should focus input when `autoFocus` prop is set", async () => { + render( {}} />); + + const input = screen.getByRole("textbox"); + await waitFor(() => { + expect(input).toHaveFocus(); + }); +}); + +test.each([ + ["small", "var(--global-size-s)"], + ["medium", "var(--global-size-m)"], + ["large", "var(--global-size-l)"], +] as const)("should render with %s `size` prop", (size, expectedHeight) => { + render( {}} />); + + const inputContainer = screen.getByTestId("input-container"); + expect(inputContainer).toHaveStyleRule("min-height", expectedHeight); +}); + +test("should apply `inputWidth` prop as percentage", () => { + render( {}} />); + + const inputContainer = screen.getByTestId("input-container"); + expect(inputContainer).toHaveStyleRule("width", "50%"); +}); + +test("should apply error border styling when `error` prop is true", () => { + render( {}} />); + + const inputContainer = screen.getByTestId("input-container"); + expect(inputContainer).toHaveStyleRule( + "border", + "var(--global-borderwidth-s) solid var(--input-validation-border-error)", + ); +}); + +test("should apply default border when `error` prop is false", () => { + render( {}} />); + + const inputContainer = screen.getByTestId("input-container"); + expect(inputContainer).toHaveStyleRule( + "border", + "var(--global-borderwidth-xs) solid var(--input-typical-border-default)", + ); +}); + +test("should apply disabled border when `disabled` prop is true", () => { + render( {}} />); + + const inputContainer = screen.getByTestId("input-container"); + expect(inputContainer).toHaveStyleRule( + "border", + "var(--global-borderwidth-xs) solid var(--input-typical-border-disabled)", + ); +}); + +test("should apply readOnly border when `readOnly` prop is true", () => { + render( {}} />); + + const inputContainer = screen.getByTestId("input-container"); + expect(inputContainer).toHaveStyleRule( + "border", + "var(--global-borderwidth-xs) solid var(--input-typical-border-read-only)", + ); +}); + +test("should apply `disabled` prop border precedence over `error` prop", () => { + render( {}} />); + + const inputContainer = screen.getByTestId("input-container"); + expect(inputContainer).toHaveStyleRule( + "border", + "var(--global-borderwidth-xs) solid var(--input-typical-border-disabled)", + ); +}); + +test("should apply `readOnly` prop border precedence over `error` prop", () => { + render( {}} />); + + const inputContainer = screen.getByTestId("input-container"); + expect(inputContainer).toHaveStyleRule( + "border", + "var(--global-borderwidth-xs) solid var(--input-typical-border-read-only)", + ); +}); + +test("should apply disabled background colour when `disabled` prop is true", () => { + render( {}} />); + + const inputContainer = screen.getByTestId("input-container"); + expect(inputContainer).toHaveStyleRule( + "background", + "var(--input-typical-bg-disabled)", + ); +}); + +test("should apply readOnly background colour when `readOnly` prop is true", () => { + render( {}} />); + + const inputContainer = screen.getByTestId("input-container"); + expect(inputContainer).toHaveStyleRule( + "background", + "var(--input-typical-bg-read-only)", + ); +}); + +test("should apply disabled text colour when `disabled` prop is true", () => { + render( {}} />); + + const input = screen.getByRole("textbox"); + expect(input).toHaveStyle("color: var(--input-typical-txt-disabled)"); +}); + +test("should apply readOnly text colour when `readOnly` prop is true", () => { + render( {}} />); + + const input = screen.getByRole("textbox"); + expect(input).toHaveStyle("color: var(--input-typical-txt-read-only)"); +}); + +test.each([ + ["small", "var(--global-font-static-body-regular-m)"], + ["medium", "var(--global-font-static-body-regular-m)"], + ["large", "var(--global-font-static-body-regular-l)"], +] as const)( + "should apply %s `size` prop font size to input", + (size, expectedFontSize) => { + render( {}} />); + + const input = screen.getByRole("textbox"); + expect(input).toHaveStyle(`font-size: ${expectedFontSize}`); + }, +); + +test.each([ + ["small", "0 var(--global-space-comp-s)"], + ["medium", "0 var(--global-space-comp-m)"], + ["large", "0 var(--global-space-comp-l)"], +] as const)( + "should apply %s `size` prop padding to input text container", + (size, expectedPadding) => { + render( {}} />); + + const inputTextContainer = screen.getByTestId("input-text-container"); + expect(inputTextContainer).toHaveStyle(`padding: ${expectedPadding}`); + }, +); + +test.each([ + ["small", "var(--global-space-comp-xs)"], + ["medium", "var(--global-space-comp-s)"], + ["large", "var(--global-space-comp-m)"], +] as const)( + "should apply %s `size` prop gap to input text container", + (size, expectedGap) => { + render( {}} />); + + const inputTextContainer = screen.getByTestId("input-text-container"); + expect(inputTextContainer).toHaveStyle(`gap: ${expectedGap}`); + }, +); + +test("should render with icon when `inputIcon` prop is provided", () => { + render( {}} />); + + const icon = screen.getByTestId("icon"); + expect(icon).toHaveAttribute("type", "bin"); +}); diff --git a/src/components/textbox/__next__/__internal__/label/index.ts b/src/components/textbox/__next__/__internal__/label/index.ts new file mode 100644 index 0000000000..2a2db856fc --- /dev/null +++ b/src/components/textbox/__next__/__internal__/label/index.ts @@ -0,0 +1 @@ +export { default } from "./label.component"; diff --git a/src/components/textbox/__next__/__internal__/label/label.component.tsx b/src/components/textbox/__next__/__internal__/label/label.component.tsx new file mode 100644 index 0000000000..59b5d15a1a --- /dev/null +++ b/src/components/textbox/__next__/__internal__/label/label.component.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import StyledLabel from "./label.style"; + +export interface LabelProps { + /** Children elements */ + children?: React.ReactNode; + /** HTML for attribute to associate label with input */ + htmlFor?: string; + /** Sets the id for the component */ + id?: string; + /** If true, uses large font size */ + isLarge?: boolean; + /** If true, displays a required indicator (*) */ + isRequired?: boolean; + /** If true, the label will display in disabled styling */ + disabled?: boolean; + /** If true, the label will display in read-only styling */ + readOnly?: boolean; +} + +export const Label = ({ + children, + id, + htmlFor, + isLarge, + isRequired, + disabled, + readOnly, +}: LabelProps) => { + return ( + + {children} + + ); +}; + +export default React.memo(Label); diff --git a/src/components/textbox/__next__/__internal__/label/label.style.ts b/src/components/textbox/__next__/__internal__/label/label.style.ts new file mode 100644 index 0000000000..8f73a2f5a1 --- /dev/null +++ b/src/components/textbox/__next__/__internal__/label/label.style.ts @@ -0,0 +1,55 @@ +import styled, { css } from "styled-components"; + +interface StyledLabelProps { + isLarge?: boolean; + isRequired?: boolean; + disabled?: boolean; + readOnly?: boolean; +} + +const StyledLabel = styled.label` + color: ${({ disabled, readOnly }) => { + if (disabled) { + return "var(--input-labelset-label-disabled)"; + } + if (readOnly) { + return "var(--input-labelset-label-readOnly)"; + } + return "var(--input-labelset-label-default)"; + }}; + + font-family: var(--fontFamiliesDefault); + + font-size: ${({ isLarge }) => + isLarge + ? "var(--global-font-static-body-regular-l)" + : "var(--global-font-static-body-regular-m)"}; + + font-style: normal; + font-weight: 500; + line-height: 150%; + + ${({ isRequired, isLarge }) => + isRequired && + css` + display: inline-flex; + align-items: center; + + ::after { + content: "*"; + color: var(--input-labelset-label-required); + font-family: var(--fontFamiliesDefault); + + font-size: ${isLarge + ? "var(--global-font-static-body-regular-l)" + : "var(--global-font-static-body-regular-m)"}; + + font-style: normal; + font-weight: 500; + line-height: 150%; + margin-left: 4px; + } + `} +`; + +export default StyledLabel; diff --git a/src/components/textbox/__next__/__internal__/label/label.test.tsx b/src/components/textbox/__next__/__internal__/label/label.test.tsx new file mode 100644 index 0000000000..bcbcfd2cd9 --- /dev/null +++ b/src/components/textbox/__next__/__internal__/label/label.test.tsx @@ -0,0 +1,138 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import Label from "."; + +test("should render", () => { + render(); + + expect(screen.getByText("Test Label")).toBeInTheDocument(); +}); + +test("should render with `htmlFor` prop", () => { + render(); + + const label = screen.getByText("Label Text"); + expect(label).toHaveAttribute("for", "input-id"); +}); + +test("should render with `id` prop", () => { + render(); + + const label = screen.getByText("Label Text"); + expect(label).toHaveAttribute("id", "label-id"); +}); + +test("should apply large font size when `isLarge` prop is true", () => { + render(); + + const label = screen.getByText("Large"); + expect(label).toHaveStyleRule( + "font-size", + "var(--global-font-static-body-regular-l)", + ); +}); + +test("should apply medium font size when `isLarge` prop is false", () => { + render(); + + const label = screen.getByText("Medium"); + expect(label).toHaveStyleRule( + "font-size", + "var(--global-font-static-body-regular-m)", + ); +}); + +test("should apply medium font size by default", () => { + render(); + + const label = screen.getByText("Default"); + expect(label).toHaveStyleRule( + "font-size", + "var(--global-font-static-body-regular-m)", + ); +}); + +test("should render required indicator when `isRequired` prop is true", () => { + render(); + + const label = screen.getByText("Required"); + expect(label).toHaveStyleRule( + "color", + "var(--input-labelset-label-required)", + { modifier: "::after" }, + ); +}); + +test("should apply large font to required indicator when `isLarge` prop is true", () => { + render( + , + ); + + const label = screen.getByText("Large Required"); + expect(label).toHaveStyleRule( + "font-size", + "var(--global-font-static-body-regular-l)", + { modifier: "::after" }, + ); +}); + +test("should apply medium font to required indicator by default", () => { + render(); + + const label = screen.getByText("Required"); + expect(label).toHaveStyleRule( + "font-size", + "var(--global-font-static-body-regular-m)", + { modifier: "::after" }, + ); +}); + +test("should apply disabled colour when `disabled` prop is true", () => { + render(); + + const label = screen.getByText("Disabled"); + expect(label).toHaveStyleRule( + "color", + "var(--input-labelset-label-disabled)", + ); +}); + +test("should apply default colour when `disabled` prop is false", () => { + render(); + + const label = screen.getByText("Enabled"); + expect(label).toHaveStyleRule("color", "var(--input-labelset-label-default)"); +}); + +test("should apply readOnly colour when `readOnly` prop is true", () => { + render(); + + const label = screen.getByText("ReadOnly"); + expect(label).toHaveStyleRule( + "color", + "var(--input-labelset-label-readOnly)", + ); +}); + +test("should apply default colour when `readOnly` prop is false", () => { + render(); + + const label = screen.getByText("Writable"); + expect(label).toHaveStyleRule("color", "var(--input-labelset-label-default)"); +}); + +test("should apply disabled colour when both `disabled` and `readOnly` props are true", () => { + render( + , + ); + + const label = screen.getByText("Both"); + expect(label).toHaveStyleRule( + "color", + "var(--input-labelset-label-disabled)", + ); +}); diff --git a/src/components/textbox/__next__/components.test-pw.tsx b/src/components/textbox/__next__/components.test-pw.tsx new file mode 100644 index 0000000000..5bc6d0f35b --- /dev/null +++ b/src/components/textbox/__next__/components.test-pw.tsx @@ -0,0 +1,51 @@ +import React, { useState } from "react"; +import TextInput, { TextInputProps } from "."; + +const MultipleTextInputComponents = (props: Partial) => { + const [state, setState] = useState(""); + const setValue = ({ target }: React.ChangeEvent) => { + setState(target.value); + }; + return ( + <> + + + + ); +}; + +const TextInputComponent = (props: Partial) => { + const [state, setState] = useState(""); + const setValue = ({ target }: React.ChangeEvent) => { + setState(target.value); + }; + return ( + + ); +}; + +const PrePopulatedTextInputComponent = (props: Partial) => { + const [state, setState] = useState("test value"); + const setValue = ({ target }: React.ChangeEvent) => { + setState(target.value); + }; + return ( + + ); +}; + +export { + MultipleTextInputComponents, + TextInputComponent, + PrePopulatedTextInputComponent, +}; diff --git a/src/components/textbox/__next__/index.ts b/src/components/textbox/__next__/index.ts new file mode 100644 index 0000000000..4c65a611e8 --- /dev/null +++ b/src/components/textbox/__next__/index.ts @@ -0,0 +1,2 @@ +export { default } from "./text-input.component"; +export type { TextInput, TextInputProps } from "./text-input.component"; diff --git a/src/components/textbox/__next__/text-input-test.stories.tsx b/src/components/textbox/__next__/text-input-test.stories.tsx new file mode 100644 index 0000000000..d55600cd63 --- /dev/null +++ b/src/components/textbox/__next__/text-input-test.stories.tsx @@ -0,0 +1,752 @@ +import React, { useState } from "react"; +import TextInput from "."; +import Box from "../../box"; +import { + CommonTextInputArgs, + commonTextInputArgTypes, + getCommonTextInputArgs, + getCommonTextInputArgsWithSpecialCharacters, +} from "./utils"; +import useMultiInput from "../../../hooks/use-multi-input"; + +export default { + title: "TextInput/Test", + parameters: { + themeProvider: { chromatic: { theme: "sage" } }, + info: { disable: true }, + chromatic: { + disableSnapshot: false, + }, + }, +}; + +export const Default = (args: CommonTextInputArgs) => { + const [state, setState] = useState(""); + const setValue = ({ + target: { value }, + }: React.ChangeEvent) => { + setState(value); + }; + return ( + + ); +}; +Default.storyName = "Default"; +Default.argTypes = commonTextInputArgTypes(); +Default.args = getCommonTextInputArgs(); +Default.parameters = { + chromatic: { disableSnapshot: true }, +}; + +export const AutoFocus = () => { + const [state, setState] = useState(""); + const setValue = ({ + target: { value }, + }: React.ChangeEvent) => { + setState(value); + }; + + return ( + + ); +}; +AutoFocus.storyName = "Auto Focus"; + +export const ValidationSmallError = () => { + const { state, setValue } = useMultiInput(); + + return ( + <> + + + + + + + + ); +}; +ValidationSmallError.storyName = "Validation - Small Error"; + +export const ValidationSmallWarning = () => { + const { state, setValue } = useMultiInput(); + + return ( + <> + + + + + + + + ); +}; +ValidationSmallWarning.storyName = "Validation - Small Warning"; + +export const ValidationMediumError = () => { + const { state, setValue } = useMultiInput(); + + return ( + <> + + + + + + + + ); +}; +ValidationMediumError.storyName = "Validation - Medium Error"; + +export const ValidationMediumWarning = () => { + const { state, setValue } = useMultiInput(); + + return ( + <> + + + + + + + + ); +}; +ValidationMediumWarning.storyName = "Validation - Medium Warning"; + +export const ValidationLargeError = () => { + const { state, setValue } = useMultiInput(); + + return ( + <> + + + + + + + + ); +}; +ValidationLargeError.storyName = "Validation - Large Error"; + +export const ValidationLargeWarning = () => { + const { state, setValue } = useMultiInput(); + + return ( + <> + + + + + + + + ); +}; +ValidationLargeWarning.storyName = "Validation - Large Warning"; + +export const ValidationInlineSmallError = () => { + const { state, setValue } = useMultiInput(); + + return ( + <> + + + + + + + + ); +}; +ValidationInlineSmallError.storyName = "Validation - Inline Small Error"; + +export const ValidationInlineSmallWarning = () => { + const { state, setValue } = useMultiInput(); + + return ( + <> + + + + + + + + ); +}; +ValidationInlineSmallWarning.storyName = "Validation - Inline Small Warning"; + +export const ValidationInlineMediumError = () => { + const { state, setValue } = useMultiInput(); + + return ( + <> + + + + + + + + ); +}; +ValidationInlineMediumError.storyName = "Validation - Inline Medium Error"; + +export const ValidationInlineMediumWarning = () => { + const { state, setValue } = useMultiInput(); + + return ( + <> + + + + + + + + ); +}; +ValidationInlineMediumWarning.storyName = "Validation - Inline Medium Warning"; + +export const ValidationInlineLargeError = () => { + const { state, setValue } = useMultiInput(); + + return ( + <> + + + + + + + + ); +}; +ValidationInlineLargeError.storyName = "Validation - Inline Large Error"; + +export const ValidationInlineLargeWarning = () => { + const { state, setValue } = useMultiInput(); + + return ( + <> + + + + + + + + ); +}; +ValidationInlineLargeWarning.storyName = "Validation - Inline Large Warning"; + +export const Disabled = () => { + const { state, setValue } = useMultiInput(); + + return ( + <> + + + + + + + ); +}; +Disabled.storyName = "Disabled"; + +export const ReadOnly = () => { + const { state, setValue } = useMultiInput(); + + return ( + <> + + + + + + + ); +}; +ReadOnly.storyName = "Read Only"; diff --git a/src/components/textbox/__next__/text-input.component.tsx b/src/components/textbox/__next__/text-input.component.tsx new file mode 100644 index 0000000000..782e914f08 --- /dev/null +++ b/src/components/textbox/__next__/text-input.component.tsx @@ -0,0 +1,181 @@ +import React, { useRef } from "react"; + +import { MarginProps } from "styled-system"; + +import Input, { InputProps } from "./__internal__/input"; +import { IconType } from "../../icon"; +import ValidationMessage from "../../../__internal__/validation-message/__next__"; +import { ValidationProps } from "../../../__internal__/validations"; +import { filterStyledSystemMarginProps } from "../../../style/utils"; +import useUniqueId from "../../../hooks/__internal__/useUniqueId"; +import guid from "../../../__internal__/utils/helpers/guid"; +import useInputAccessibility from "../../../hooks/__internal__/useInputAccessibility/useInputAccessibility"; +import ErrorBorder from "./__internal__/error-border/error-border.style"; +import { TagProps } from "../../../__internal__/utils/helpers/tags"; +import { StyledTextInput, LabelSet, InputSet } from "./text-input.style"; +import HintText from "./__internal__/hint-text"; +import Label from "./__internal__/label"; + +export interface TextInputProps + extends Omit, + MarginProps, + Omit, + TagProps { + /** + * Unique identifier for the input. + * Label id will be based on it, using following pattern: [id]-label. + * Will use a randomly generated GUID if none is provided + */ + id?: string; + /** Label content */ + label: string; + /** When true label is inline */ + labelInline?: boolean; + /** Size of an input */ + size?: "small" | "medium" | "large"; + /** A hint string rendered before the input but after the label. Intended to describe the purpose or content of the input */ + inputHint?: string; + /** Type of the icon that will be rendered next to the input */ + inputIcon?: IconType; + /** The width of the component container as a percentage (e.g., 50 for 50%) */ + containerWidth?: number; + /** The width of the input as a percentage (e.g., 50 for 50%) */ + inputWidth?: number; +} + +const labelInlineDefault = false; + +export const TextInput = React.forwardRef( + ( + { + id, + label, + labelInline = labelInlineDefault, + disabled, + readOnly, + size = "medium", + prefix, + inputHint, + inputIcon, + placeholder, + value, + containerWidth = 100, + inputWidth, + error, + warning, + required, + name, + "data-element": dataElement, + "data-role": dataRole, + ...props + }: TextInputProps, + ref: React.ForwardedRef, + ) => { + const validationState = { error, warning }; + const stateProps = { disabled, readOnly }; + + const [uniqueId, uniqueName] = useUniqueId(id, name); + + const { labelId, validationId, ariaDescribedBy } = useInputAccessibility({ + id: uniqueId, + validationRedesignOptIn: true, + error: validationState.error, + warning: validationState.warning, + label, + }); + + const hintId = useRef(guid()); + const inputHintId = inputHint ? hintId.current : undefined; + + const defaultInlineInputWidth = 80; + const defaultNonInlineInputWidth = 100; + const resolvedInputWidth = + inputWidth ?? + (labelInline ? defaultInlineInputWidth : defaultNonInlineInputWidth); + + const ariaDescribedByString = [inputHintId, ariaDescribedBy] + .filter(Boolean) + .join(" "); + + const validationMessage = ( + + ); + + const errorBorder = (validationState.error || validationState.warning) && ( + + ); + + return ( + + + + {inputHint && ( + + {inputHint} + + )} + + + + {validationMessage} + {errorBorder} + + + ); + }, +); + +export default TextInput; diff --git a/src/components/textbox/__next__/text-input.mdx b/src/components/textbox/__next__/text-input.mdx new file mode 100644 index 0000000000..dfc465f576 --- /dev/null +++ b/src/components/textbox/__next__/text-input.mdx @@ -0,0 +1,97 @@ +import { Meta, ArgTypes, Canvas } from "@storybook/blocks"; +import * as TextInputStories from "./text-input.stories"; + + + +# TextInput + + + Product Design System component + + +Captures a single line of text. + +## Contents + +- [Quick Start](#quick-start) +- [Examples](#examples) +- [Validation states](#validation-states) +- [Props](#props) +- [Translation keys](#translation-keys) + +## Quick Start + +```javascript +import TextInput from "carbon-react/lib/components/textbox/__next__"; +``` + +## Designer Notes + +- Use placeholder text to give the user examples of data formats (e.g. AB123456C for a UK National Insurance number). +- You can disable a text input, but try to avoid this. If you need to, make it clear what the user needs to do in order to activate the text input. +- Use wider fields for longer data (e.g. an address line), and narrower fields for shorter data (e.g. a postcode), to give the user a clue about the data expected. + +## Examples + +### Default + + + +### Required + + + +### Placeholder + + + +### With Input Hint + +When the `inputHint` prop is passed, please use a full stop `.` at the end. This forces a pause +before any other announcements, this well help screen reader users understand the hint fully. + + + +### With Input Icon + + + +### Disabled + + + +### Read-Only + + + +### Label Inline + + + +### Sizes + + + +### Input Width + +When `labelInline` is true, `inputWidth` controls the width of the input while maintaining visual balance with the label. +When `labelInline` is false, `inputWidth` only affects the input width; the label width remains unchanged. + + + +### Container Width + +`containerWidth` controls the width of the entire component container which contains the input and the label. + + + +## Props + + + +**Any other supplied props will be provided to the underlying HTML input element** diff --git a/src/components/textbox/__next__/text-input.pw.tsx b/src/components/textbox/__next__/text-input.pw.tsx new file mode 100644 index 0000000000..37f773fb09 --- /dev/null +++ b/src/components/textbox/__next__/text-input.pw.tsx @@ -0,0 +1,364 @@ +import React from "react"; +import { test, expect } from "../../../../playwright/helpers/base-test"; +import { checkAccessibility } from "../../../../playwright/support/helper"; +import { + MultipleTextInputComponents, + TextInputComponent, + PrePopulatedTextInputComponent, +} from "./components.test-pw"; +import { TextInputProps } from "."; + +test("typing updates the input value", async ({ mount, page }) => { + await mount(); + const input = page.locator("input"); + + await input.fill("hello"); + await expect(input).toHaveValue("hello"); +}); + +test("paste operation inserts text", async ({ mount, page, context }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await mount(); + const input = page.locator("input"); + + await page.evaluate(() => { + return navigator.clipboard.writeText("hello world"); + }); + + await input.focus(); + await input.press("ControlOrMeta+v"); + + await expect(input).toHaveValue("hello world"); +}); + +test("cut removes selected text", async ({ mount, page }) => { + await mount(); + const input = page.locator("input"); + + await input.fill("hello world"); + await input.selectText(); + await input.press("ControlOrMeta+x"); + await expect(input).toHaveValue(""); +}); + +test("tab focuses the input", async ({ mount, page }) => { + await mount(); + const input = page.locator("input"); + + await page.keyboard.press("Tab"); + await expect(input).toBeFocused(); +}); + +test("shift+tab moves focus away", async ({ mount, page }) => { + await mount(); + const input = page.locator("input").first(); + + await input.focus(); + await page.keyboard.press("Shift+Tab"); + await expect(input).not.toBeFocused(); +}); + +test("backspace deletes text", async ({ mount, page }) => { + await mount(); + const input = page.locator("input"); + + await input.fill("hello"); + await input.press("End"); + await input.press("Backspace"); + await expect(input).toHaveValue("hell"); +}); + +test("arrow keys navigate within text", async ({ mount, page }) => { + await mount(); + const input = page.locator("input"); + + await input.fill("hello"); + await input.press("Home"); + await input.press("ArrowRight"); + await input.press("ArrowRight"); + + await page.keyboard.type("X"); + await expect(input).toHaveValue("heXllo"); +}); + +test("mouse selection selects text", async ({ mount, page }) => { + await mount(); + const input = page.locator("input"); + + await input.fill("hello world"); + + await input.click({ clickCount: 3 }); + + const selectedText = await page.evaluate(() => { + const el = document.querySelector("input") as HTMLInputElement; + const start = el.selectionStart ?? 0; + const end = el.selectionEnd ?? 0; + return el.value.substring(start, end); + }); + expect(selectedText).toBe("hello world"); +}); + +test("shift+arrow keys select text", async ({ mount, page }) => { + await mount(); + const input = page.locator("input"); + + await input.fill("hello"); + await input.press("Home"); + await input.press("Shift+ArrowRight"); + await input.press("Shift+ArrowRight"); + + const selectedText = await page.evaluate(() => { + const el = document.querySelector("input") as HTMLInputElement; + const start = el.selectionStart ?? 0; + const end = el.selectionEnd ?? 0; + return el.value.substring(start, end); + }); + expect(selectedText).toBe("he"); +}); + +test("delete key removes text after cursor", async ({ mount, page }) => { + await mount(); + const input = page.locator("input"); + + await input.fill("hello"); + await input.press("Home"); + await input.press("Delete"); + await expect(input).toHaveValue("ello"); +}); + +test("disabled input cannot be focused", async ({ mount, page }) => { + await mount(); + const input = page.locator("input"); + + await page.keyboard.press("Tab"); + await expect(input).not.toBeFocused(); +}); + +test("disabled input does not accept input", async ({ mount, page }) => { + await mount(); + const input = page.locator("input"); + + await input.focus(); + await page.keyboard.type("test"); + await expect(input).toHaveValue(""); +}); + +test("readOnly input can be focused", async ({ mount, page }) => { + await mount(); + const input = page.locator("input"); + + await page.keyboard.press("Tab"); + await expect(input).toBeFocused(); +}); + +test("readOnly input does not accept input", async ({ mount, page }) => { + await mount(); + const input = page.locator("input"); + + await input.focus(); + await page.keyboard.type("test"); + await expect(input).toHaveValue(""); +}); + +test("multiple typing events accumulate correctly", async ({ mount, page }) => { + await mount(); + const input = page.locator("input"); + + await input.focus(); + await page.keyboard.type("hello"); + await page.keyboard.type(" world"); + await expect(input).toHaveValue("hello world"); +}); + +test("placeholder displays when input is empty", async ({ mount, page }) => { + await mount(); + const input = page.locator("input"); + + await expect(input).toHaveAttribute("placeholder", "Enter your name"); +}); + +test("placeholder disappears when typing", async ({ mount, page }) => { + await mount(); + const input = page.locator("input"); + + await input.focus(); + await page.keyboard.type("John"); + + await expect(input).toHaveAttribute("placeholder", "Enter your name"); + + await expect(input).toHaveValue("John"); +}); + +test("placeholder reappears when text is cleared", async ({ mount, page }) => { + await mount(); + const input = page.locator("input"); + + await input.fill("John"); + await input.clear(); + + await expect(input).toHaveAttribute("placeholder", "Enter your name"); +}); + +test("clicking label focuses the input", async ({ mount, page }) => { + await mount(); + const label = page.locator("label"); + const input = page.locator("input"); + + await label.click(); + await expect(input).toBeFocused(); +}); + +test("label text displays correctly", async ({ mount, page }) => { + await mount(); + const label = page.locator("label"); + + await expect(label).toHaveText("Email Address"); +}); + +test("very long text input is handled correctly", async ({ mount, page }) => { + await mount(); + const input = page.locator("input"); + + const longText = "a".repeat(1000); + await input.fill(longText); + + await expect(input).toHaveValue(longText); +}); + +test("special characters and unicode are accepted", async ({ mount, page }) => { + await mount(); + const input = page.locator("input"); + + const specialText = "Hello! @#$% 你好 🎉 Ñoño"; + await input.fill(specialText); + + await expect(input).toHaveValue(specialText); +}); + +test("leading and trailing whitespace is preserved", async ({ + mount, + page, +}) => { + await mount(); + const input = page.locator("input"); + + const textWithWhitespace = " hello world "; + await input.fill(textWithWhitespace); + + await expect(input).toHaveValue(textWithWhitespace); +}); + +test("input handles empty string gracefully", async ({ mount, page }) => { + await mount(); + const input = page.locator("input"); + + await input.fill(""); + + await expect(input).toHaveValue(""); +}); + +test("copy operation copies selected text to clipboard", async ({ + mount, + page, +}) => { + await mount(); + const input = page.locator("input"); + + await input.fill("hello world"); + await input.selectText(); + + const copiedText = await page.evaluate(() => { + const el = document.querySelector("input") as HTMLInputElement; + const start = el.selectionStart ?? 0; + const end = el.selectionEnd ?? 0; + return el.value.substring(start, end); + }); + + expect(copiedText).toBe("hello world"); +}); + +test.describe("Accessibility tests", () => { + test("passes accessibility tests with default configuration", async ({ + mount, + page, + }) => { + await mount(); + await checkAccessibility(page); + }); + + test("passes accessibility tests with autoFocus prop", async ({ + mount, + page, + }) => { + await mount(); + await checkAccessibility(page); + }); + + test("passes accessibility tests when disabled", async ({ mount, page }) => { + await mount(); + await checkAccessibility(page); + }); + + test("passes accessibility tests when readOnly", async ({ mount, page }) => { + await mount(); + await checkAccessibility(page); + }); + + test("passes accessibility tests with inputHint prop", async ({ + mount, + page, + }) => { + await mount(); + await checkAccessibility(page); + }); + + test("passes accessibility tests with required prop", async ({ + mount, + page, + }) => { + await mount(); + await checkAccessibility(page); + }); + + (["small", "medium", "large"] as TextInputProps["size"][]).forEach((size) => { + test(`passes accessibility tests with size ${size}`, async ({ + mount, + page, + }) => { + await mount(); + await checkAccessibility(page); + }); + }); + + test("passes accessibility tests with warning boolean prop", async ({ + mount, + page, + }) => { + await mount(); + await checkAccessibility(page); + }); + + test("passes accessibility tests with warning string prop", async ({ + mount, + page, + }) => { + await mount(); + await checkAccessibility(page); + }); + + test("passes accessibility tests with error boolean prop", async ({ + mount, + page, + }) => { + await mount(); + await checkAccessibility(page); + }); + + test("passes accessibility tests with error string prop", async ({ + mount, + page, + }) => { + await mount(); + await checkAccessibility(page); + }); +}); diff --git a/src/components/textbox/__next__/text-input.stories.tsx b/src/components/textbox/__next__/text-input.stories.tsx new file mode 100644 index 0000000000..d2ee8107e6 --- /dev/null +++ b/src/components/textbox/__next__/text-input.stories.tsx @@ -0,0 +1,345 @@ +import React, { useState } from "react"; +import { Meta } from "@storybook/react"; +import generateStyledSystemProps from "../../../../.storybook/utils/styled-system-props"; +import Box from "../../box"; +import TextInput from "."; + +const styledSystemProps = generateStyledSystemProps({ + margin: true, +}); + +const meta: Meta = { + title: "TextInput", + component: TextInput, + parameters: { + themeProvider: { chromatic: { theme: "sage" } }, + chromatic: { + disableSnapshot: true, + }, + }, + argTypes: { + ...styledSystemProps, + }, +}; + +export default meta; + +export const Default = () => { + const [state, setState] = useState(""); + + const setValue = ({ target }: React.ChangeEvent) => { + setState(target.value); + }; + + return ; +}; +Default.storyName = "Default"; + +export const Required = () => { + const [state, setState] = useState(""); + + const setValue = ({ target }: React.ChangeEvent) => { + setState(target.value); + }; + + return ( + + ); +}; + +Required.storyName = "Required"; + +export const Placeholder = () => { + const [state, setState] = useState(""); + + const setValue = ({ target }: React.ChangeEvent) => { + setState(target.value); + }; + + return ( + + ); +}; + +Placeholder.storyName = "Placeholder"; +Placeholder.parameters = { + chromatic: { disableSnapshot: false }, +}; + +export const WithInputHint = () => { + const [state, setState] = useState(""); + + const setValue = ({ target }: React.ChangeEvent) => { + setState(target.value); + }; + + return ( + + ); +}; + +WithInputHint.storyName = "With Input Hint"; + +export const WithInputIcon = () => { + const [state, setState] = useState(""); + + const setValue = ({ target }: React.ChangeEvent) => { + setState(target.value); + }; + + return ( + + ); +}; + +WithInputIcon.storyName = "With Input Icon"; + +export const Disabled = () => { + return ( + {}} + /> + ); +}; + +Disabled.storyName = "Disabled"; + +export const ReadOnly = () => { + return ( + {}} + /> + ); +}; + +ReadOnly.storyName = "Read-Only"; + +export const LabelInline = () => { + const [state, setState] = useState(""); + + const setValue = ({ target }: React.ChangeEvent) => { + setState(target.value); + }; + + return ( + + ); +}; + +LabelInline.storyName = "Label Inline"; + +export const InputWidth = () => { + const [state, setState] = useState(""); + + const setValue = ({ target }: React.ChangeEvent) => { + setState(target.value); + }; + + return ( + + + + + + + + + ); +}; + +InputWidth.storyName = "Input Width"; +InputWidth.parameters = { + chromatic: { disableSnapshot: false }, +}; + +export const ContainerWidth = () => { + const [state, setState] = useState(""); + + const setValue = ({ target }: React.ChangeEvent) => { + setState(target.value); + }; + + return ( + + + + + + + + + ); +}; + +ContainerWidth.storyName = "Container Width"; +ContainerWidth.parameters = { + chromatic: { disableSnapshot: false }, +}; + +export const Sizes = () => { + const [state, setState] = useState(""); + + const setValue = ({ target }: React.ChangeEvent) => { + setState(target.value); + }; + + return ( + + + + + + + + + ); +}; + +Sizes.storyName = "Sizes"; diff --git a/src/components/textbox/__next__/text-input.style.ts b/src/components/textbox/__next__/text-input.style.ts new file mode 100644 index 0000000000..1494187f15 --- /dev/null +++ b/src/components/textbox/__next__/text-input.style.ts @@ -0,0 +1,82 @@ +import styled from "styled-components"; +import { margin } from "styled-system"; +import applyBaseTheme from "../../../style/themes/apply-base-theme"; + +interface StyledTextInputProps { + labelInline?: boolean; + containerWidth?: number; + size?: "small" | "medium" | "large"; +} + +interface LabelSetProps { + labelInline?: boolean; + labelSetWidth?: number; +} + +interface InputSetProps { + inputWidth?: number; + size?: "small" | "medium" | "large"; +} + +const StyledTextInput = styled.div.attrs(applyBaseTheme)` + ${margin} + + display: flex; + flex-direction: ${({ labelInline }) => (labelInline ? "row" : "column")}; + ${({ containerWidth }) => containerWidth && `width: ${containerWidth}%;`} + align-items: flex-start; + + gap: ${({ labelInline, size }) => { + if (!labelInline) { + switch (size) { + case "small": + return "var(--global-space-comp-xs)"; + case "large": + return "var(--global-space-comp-m)"; + default: + return "var(--global-space-comp-s)"; + } + } + switch (size) { + case "large": + return "var(--global-space-comp-xl)"; + default: + return "var(--global-space-comp-l)"; + } + }}; +`; + +const LabelSet = styled.div` + display: flex; + flex-direction: column; + ${({ labelInline }) => + labelInline && "padding-top: var(--global-space-comp-s);"} + ${({ labelSetWidth }) => labelSetWidth && `width: ${labelSetWidth}%;`} + + justify-content: ${({ labelInline }) => + labelInline ? "flex-start" : "flex-end"}; + + align-items: ${({ labelInline }) => + labelInline ? "flex-end" : "flex-start"}; + + align-self: stretch; +`; + +const InputSet = styled.div` + display: flex; + flex-direction: column; + position: relative; + ${({ inputWidth }) => inputWidth && `width: ${inputWidth}%;`} + + gap: ${({ size }) => { + switch (size) { + case "small": + return "var(--global-space-comp-xs)"; + default: + return "var(--global-space-comp-s)"; + } + }}; +`; + +export { StyledTextInput, LabelSet, InputSet }; +export type { StyledTextInputProps, LabelSetProps }; diff --git a/src/components/textbox/__next__/text-input.test.tsx b/src/components/textbox/__next__/text-input.test.tsx new file mode 100644 index 0000000000..9dcb29e5a1 --- /dev/null +++ b/src/components/textbox/__next__/text-input.test.tsx @@ -0,0 +1,369 @@ +import React from "react"; +import { act, render, screen } from "@testing-library/react"; +import TextInput from "."; +import { testStyledSystemMargin } from "../../../__spec_helper__/__internal__/test-utils"; +import createGuid from "../../../__internal__/utils/helpers/guid"; + +jest.mock("../../../__internal__/utils/logger"); + +const mockedGuid = "mocked-guid"; +jest.mock("../../../__internal__/utils/helpers/guid"); + +(createGuid as jest.MockedFunction).mockReturnValue( + mockedGuid, +); + +testStyledSystemMargin( + (props) => ( + {}} + {...props} + /> + ), + () => screen.getByTestId("text-input-wrapper"), +); + +test("should render", () => { + render( {}} />); + + expect(screen.getByRole("textbox")).toBeVisible(); + expect(screen.getByRole("textbox")).toHaveAccessibleName("label"); +}); + +test("should render with an id attribute via the `id` prop", () => { + render( + {}} />, + ); + + expect(screen.getByRole("textbox")).toHaveAttribute("id", "id-123"); +}); + +test("should render with an id attribute via a guid if the `id` prop is not provided", () => { + render( {}} />); + + expect(screen.getByRole("textbox")).toHaveAttribute("id", mockedGuid); +}); + +test("should render with data attributes via the `data-element` and `data-role` props", () => { + render( + {}} + />, + ); + + expect(screen.getByTestId("custom-role")).toHaveAttribute( + "data-element", + "custom-element", + ); +}); + +test("should render with a placeholder via the `placeholder` prop", () => { + render( + {}} + />, + ); + + expect(screen.getByRole("textbox")).toHaveAttribute("placeholder", "baz"); +}); + +test("should not render with a placeholder when `disabled` prop is true", () => { + render( + {}} + />, + ); + + expect(screen.getByRole("textbox")).not.toHaveAttribute("placeholder", "baz"); +}); + +test("should not render with a placeholder when `readOnly` prop is true", () => { + render( + {}} + />, + ); + + expect(screen.getByRole("textbox")).not.toHaveAttribute("placeholder", "baz"); +}); + +test("should render with an input hint via the `inputHint` prop", () => { + render( + {}} />, + ); + + expect(screen.getByRole("textbox")).toHaveAccessibleDescription("baz"); +}); + +test("should render with an error border via the `error` prop", () => { + render( + {}} + />, + ); + + const errorBorder = screen.getByTestId("error-border"); + + expect(errorBorder).toBeVisible(); +}); + +test("should render with an error message via the `error` prop", () => { + render( + {}} + />, + ); + + expect(screen.getByRole("textbox")).toHaveAccessibleDescription("error"); +}); + +test("should announce the error message after the input hint", () => { + render( + {}} + />, + ); + + expect(screen.getByRole("textbox")).toHaveAccessibleDescription( + "hint text error", + ); +}); + +test("should render with a warning border via the `warning` prop", () => { + render( + {}} + />, + ); + + const errorBorder = screen.getByTestId("error-border"); + + expect(errorBorder).toBeVisible(); +}); + +test("should render with a warning message via the `warning` prop", () => { + render( + {}} + />, + ); + + expect(screen.getByRole("textbox")).toHaveAccessibleDescription("warning"); +}); + +test("should call the `onFocus` callback prop when the input is focused", async () => { + const onFocus = jest.fn(); + + render( + {}} + />, + ); + + expect(onFocus).not.toHaveBeenCalled(); + + act(() => { + screen.getByRole("textbox").focus(); + }); + + expect(onFocus).toHaveBeenCalled(); +}); + +test("should call the `onBlur` callback prop when the input is blurred", async () => { + const onBlur = jest.fn(); + + render( + {}} />, + ); + + act(() => { + screen.getByRole("textbox").focus(); + }); + + expect(onBlur).not.toHaveBeenCalled(); + + act(() => { + screen.getByRole("textbox").blur(); + }); + + expect(onBlur).toHaveBeenCalled(); +}); + +test("should apply flex-direction row when `labelInline` prop is true", () => { + render( + {}} + labelInline + />, + ); + const locator = screen.getByTestId("text-input"); + expect(locator).toHaveStyleRule("flex-direction", "row"); +}); + +test("should apply flex-direction column when `labelInline` prop is false", () => { + render( + {}} + labelInline={false} + />, + ); + const locator = screen.getByTestId("text-input"); + expect(locator).toHaveStyleRule("flex-direction", "column"); +}); + +test("should apply `containerWidth` prop as percentage when provided as number", () => { + render( + {}} + containerWidth={50} + />, + ); + const locator = screen.getByTestId("text-input"); + expect(locator).toHaveStyleRule("width", "50%"); +}); + +test.each([ + ["small", "var(--global-space-comp-l)"], + ["medium", "var(--global-space-comp-l)"], + ["large", "var(--global-space-comp-xl)"], +] as const)( + "should apply correct gap when `labelInline` prop is true and `size` prop is %s", + (size, expectedGap) => { + render( + {}} + labelInline + size={size} + />, + ); + const locator = screen.getByTestId("text-input"); + expect(locator).toHaveStyleRule("gap", expectedGap); + }, +); + +test.each([ + ["small", "var(--global-space-comp-xs)"], + ["medium", "var(--global-space-comp-s)"], + ["large", "var(--global-space-comp-m)"], +] as const)( + "should apply correct gap when `labelInline` prop is false and `size` prop is %s", + (size, expectedGap) => { + render( + {}} + labelInline={false} + size={size} + />, + ); + const locator = screen.getByTestId("text-input"); + expect(locator).toHaveStyleRule("gap", expectedGap); + }, +); + +test("should apply padding-top small for LabelSet when `labelInline` prop is true and `size` prop is medium", () => { + render( + {}} + labelInline + size="medium" + />, + ); + const locator = screen.getByTestId("label-set"); + expect(locator).toHaveStyleRule("padding-top", "var(--global-space-comp-s)"); +}); + +test("should calculate labelSetWidth as 100 minus `inputWidth` prop when `labelInline` prop is true", () => { + render( + {}} + labelInline + inputWidth={30} + />, + ); + const locator = screen.getByTestId("label-set"); + expect(locator).toHaveStyleRule("width", "70%"); +}); + +test("should apply `inputWidth` prop as percentage when provided as number", () => { + render( + {}} inputWidth={75} />, + ); + const locator = screen.getByTestId("input-set"); + expect(locator).toHaveStyleRule("width", "75%"); +}); + +test.each([ + ["small", "var(--global-space-comp-xs)"], + ["medium", "var(--global-space-comp-s)"], + ["large", "var(--global-space-comp-s)"], +] as const)( + "should apply correct gap to InputSet when `size` prop is %s", + (size, expectedGap) => { + render( + {}} size={size} />, + ); + const locator = screen.getByTestId("input-set"); + expect(locator).toHaveStyleRule("gap", expectedGap); + }, +); diff --git a/src/components/textbox/__next__/utils.ts b/src/components/textbox/__next__/utils.ts new file mode 100644 index 0000000000..7baceb5fab --- /dev/null +++ b/src/components/textbox/__next__/utils.ts @@ -0,0 +1,93 @@ +import { ICONS } from "../../icon/icon-config"; + +export interface CommonTextInputArgs { + label: string; + placeholder: string; +} + +export const getCommonTextInputArgsWithSpecialCharacters = ( + args: CommonTextInputArgs, +) => { + const { label, placeholder } = args; + return { + ...args, + label, + placeholder, + }; +}; + +export const getCommonTextInputArgs = (labelInlineDefault = false) => { + return { + disabled: false, + readOnly: false, + prefix: "", + label: labelInlineDefault ? "Label - new validation" : "Label", + inputHint: "", + placeholder: "", + labelInline: labelInlineDefault, + inputWidth: 50, + containerWidth: 100, + size: "medium", + inputIcon: undefined, + required: false, + characterLimit: undefined, + error: "", + warning: "", + }; +}; + +export const commonTextInputArgTypes = () => ({ + disabled: { + control: { + type: "boolean", + }, + }, + size: { + options: ["small", "medium", "large"], + control: { + type: "select", + }, + }, + inputIcon: { + options: ["", ...ICONS], + control: { + type: "select", + }, + }, + labelInline: { + control: { + type: "boolean", + }, + }, + inputWidth: { + control: { + type: "range", + min: 0, + max: 100, + step: 1, + }, + }, + containerWidth: { + control: { + type: "range", + min: 0, + max: 100, + step: 1, + }, + }, + required: { + control: { + type: "boolean", + }, + }, + error: { + control: { + type: "text", + }, + }, + warning: { + control: { + type: "text", + }, + }, +}); diff --git a/src/components/textbox/textbox-test.stories.tsx b/src/components/textbox/textbox-test.stories.tsx index a227c1b59b..7ca96a402e 100644 --- a/src/components/textbox/textbox-test.stories.tsx +++ b/src/components/textbox/textbox-test.stories.tsx @@ -13,7 +13,7 @@ import { import useMultiInput from "../../hooks/use-multi-input"; export default { - title: "Textbox/Test", + title: "Deprecated/Textbox/Test", parameters: { themeProvider: { chromatic: { theme: "sage" } }, info: { disable: true }, @@ -124,10 +124,6 @@ export const Validation = () => { ); }; Validation.storyName = "Validation"; -Validation.parameters = { - chromatic: { disableSnapshot: false }, - themeProvider: { chromatic: { theme: "sage" } }, -}; export const NewValidation = () => { const { state, setValue } = useMultiInput(); @@ -168,10 +164,6 @@ export const NewValidation = () => { ); }; NewValidation.storyName = "New Validation"; -NewValidation.parameters = { - chromatic: { disableSnapshot: false }, - themeProvider: { chromatic: { theme: "sage" } }, -}; export const PrefixWithSizes = () => { const { state, setValue } = useMultiInput(); @@ -257,10 +249,6 @@ export const LabelAndHintTextAlign = () => { ); }; LabelAndHintTextAlign.storyName = "Label and hint text align"; -LabelAndHintTextAlign.parameters = { - chromatic: { disableSnapshot: false }, - themeProvider: { chromatic: { theme: "sage" } }, -}; export const AutoFocus = () => { const [state, setState] = useState("Textbox"); @@ -274,10 +262,6 @@ export const AutoFocus = () => { ); }; AutoFocus.storyName = "Auto Focus"; -AutoFocus.parameters = { - chromatic: { disableSnapshot: false }, - themeProvider: { chromatic: { theme: "sage" } }, -}; export const FormFieldRelativePosition = () => { const [state, setState] = useState("Textbox"); diff --git a/src/components/textbox/textbox.mdx b/src/components/textbox/textbox.mdx index 4aa7b92aa2..57b5f7e1dd 100644 --- a/src/components/textbox/textbox.mdx +++ b/src/components/textbox/textbox.mdx @@ -1,5 +1,6 @@ import { Meta, ArgTypes, Canvas } from "@storybook/blocks"; import TranslationKeysTable from "../../../.storybook/utils/translation-keys-table"; +import DeprecationWarning from "../../../.storybook/utils/deprecation-warning.component" import * as TextboxStories from "./textbox.stories"; @@ -18,6 +19,12 @@ import * as TextboxStories from "./textbox.stories"; Captures a single line of text. + +This version of `Textbox` is deprecated and has been replaced by a new version named `TextInput` located in the `__next__` directory. + +Please see the new documentation page for more information. + + ## Contents - [Quick Start](#quick-start) diff --git a/src/components/textbox/textbox.stories.tsx b/src/components/textbox/textbox.stories.tsx index f87c3e896f..c14991ed68 100644 --- a/src/components/textbox/textbox.stories.tsx +++ b/src/components/textbox/textbox.stories.tsx @@ -12,10 +12,13 @@ const styledSystemProps = generateStyledSystemProps({ }); const meta: Meta = { - title: "Textbox", + title: "Deprecated/Textbox", component: Textbox, parameters: { themeProvider: { chromatic: { theme: "sage" } }, + chromatic: { + disableSnapshot: true, + }, }, argTypes: { ...styledSystemProps, @@ -208,7 +211,6 @@ export const WithLabelInline: Story = () => { ); }; WithLabelInline.storyName = "With Label Inline"; -WithLabelInline.parameters = { chromatic: { disableSnapshot: true } }; export const WithCustomLabelWidthAndInputWidth: Story = () => { const [state, setState] = useState("Textbox");