diff --git a/packages/core/src/components/LegacySearch/__tests__/__snapshots__/Search.snapshot.test.tsx.snap b/packages/core/src/components/LegacySearch/__tests__/__snapshots__/Search.snapshot.test.tsx.snap index eb3d5ed191..99de6abcce 100644 --- a/packages/core/src/components/LegacySearch/__tests__/__snapshots__/Search.snapshot.test.tsx.snap +++ b/packages/core/src/components/LegacySearch/__tests__/__snapshots__/Search.snapshot.test.tsx.snap @@ -23,7 +23,6 @@ exports[`Search renders correctly when disabled 1`] = ` data-testid="search_search" disabled={true} id="search" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -124,7 +123,6 @@ exports[`Search renders correctly with className 1`] = ` data-testid="search_search" disabled={false} id="search" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -225,7 +223,6 @@ exports[`Search renders correctly with icon 1`] = ` data-testid="search_search" disabled={false} id="search" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -316,7 +313,6 @@ exports[`Search renders correctly with id 1`] = ` data-testid="search_testId" disabled={false} id="testId" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -417,7 +413,6 @@ exports[`Search renders correctly with loading 1`] = ` data-testid="search_search" disabled={false} id="search" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -547,7 +542,6 @@ exports[`Search renders correctly with placeholder 1`] = ` data-testid="search_search" disabled={false} id="search" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -648,7 +642,6 @@ exports[`Search renders correctly with secondaryIconName 1`] = ` data-testid="search_search" disabled={false} id="search" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -739,7 +732,6 @@ exports[`Search renders correctly with underline type 1`] = ` data-testid="search_search" disabled={false} id="search" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -840,7 +832,6 @@ exports[`Search renders correctly with validation 1`] = ` data-testid="search_search" disabled={false} id="search" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -951,7 +942,6 @@ exports[`Search renders correctly with value 1`] = ` data-testid="search_search" disabled={false} id="search" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -1052,7 +1042,6 @@ exports[`Search renders correctly with wrapperClassName 1`] = ` data-testid="search_search" disabled={false} id="search" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -1153,7 +1142,6 @@ exports[`Search renders correctly without props 1`] = ` data-testid="search_search" disabled={false} id="search" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} diff --git a/packages/core/src/components/TextField/TextField.module.scss b/packages/core/src/components/TextField/TextField.module.scss index f79d177992..024a52338a 100644 --- a/packages/core/src/components/TextField/TextField.module.scss +++ b/packages/core/src/components/TextField/TextField.module.scss @@ -206,8 +206,11 @@ color: var(--negative-color); } -.textField .inputWrapper.inputErrorValidation + .subTextContainer .subTextContainerStatus { - color: var(--negative-color); +.textField .inputWrapper.inputErrorValidation + .subTextContainer { + .subTextContainerStatus, + .counter { + color: var(--negative-color); + } } .textField .inputWrapper.inputSuccessValidation:hover .input { diff --git a/packages/core/src/components/TextField/TextField.tsx b/packages/core/src/components/TextField/TextField.tsx index d4eb742d55..e958e6f870 100644 --- a/packages/core/src/components/TextField/TextField.tsx +++ b/packages/core/src/components/TextField/TextField.tsx @@ -33,6 +33,7 @@ import { ComponentDefaultTestId } from "../../tests/constants"; import { VibeComponentProps, VibeComponent, withStaticProps } from "../../types"; import styles from "./TextField.module.scss"; import { Tooltip } from "../Tooltip"; +import { HiddenText } from "../HiddenText"; const EMPTY_OBJECT = { primary: "", secondary: "", layout: "" }; @@ -79,6 +80,7 @@ export interface TextFieldProps extends VibeComponentProps { /** TEXT_TYPES is exposed on the component itself */ type?: TextFieldTextType; maxLength?: number; + allowExceedingMaxLength?: boolean; trim?: boolean; /** ARIA role for container landmark */ role?: string; @@ -146,6 +148,7 @@ const TextField: VibeComponent & { iconsNames = EMPTY_OBJECT, type = TextFieldTextType.TEXT, maxLength = null, + allowExceedingMaxLength = false, trim = false, role = "", required = false, @@ -241,12 +244,16 @@ const TextField: VibeComponent & { }, [disabled, clearOnIconClick, onIconClick, currentStateIconName, controlled, onChangeCallback, clearValue]); const validationClass = useMemo(() => { + if (typeof maxLength === "number" && inputValue.length > maxLength) { + return FEEDBACK_CLASSES.error; + } + if ((!validation || !validation.status) && !isRequiredAndEmpty) { return ""; } const status = isRequiredAndEmpty ? "error" : validation.status; return FEEDBACK_CLASSES[status]; - }, [validation, isRequiredAndEmpty]); + }, [validation, isRequiredAndEmpty, inputValue]); const hasIcon = iconName || secondaryIconName; const shouldShowExtraText = showCharCount || (validation && validation.text) || isRequiredAndEmpty; @@ -255,6 +262,7 @@ const TextField: VibeComponent & { const shouldFocusOnPrimaryIcon = (onIconClick !== NOOP || iconsNames.primary || iconTooltipContent) && inputValue && iconName.length && isPrimary; const shouldFocusOnSecondaryIcon = (secondaryIconName || secondaryTooltipContent) && isSecondary && !!inputValue; + const allowExceedingMaxLengthTextId = allowExceedingMaxLength ? `${id}-allow-exceeding-max-length-text` : undefined; useEffect(() => { if (!inputRef?.current || !autoFocus) { @@ -306,13 +314,14 @@ const TextField: VibeComponent & { onFocus={onFocus} onKeyDown={onKeyDown} onWheel={onWheel} - maxLength={maxLength} + maxLength={typeof maxLength === "number" && !allowExceedingMaxLength ? maxLength : undefined} role={searchResultsContainerId && "combobox"} // For voice reader aria-label={inputAriaLabel || placeholder} aria-invalid={(validation && validation.status === "error") || isRequiredAndEmpty} aria-owns={searchResultsContainerId} aria-activedescendant={activeDescendant} aria-required={required} + aria-describedby={allowExceedingMaxLengthTextId} required={required} tabIndex={tabIndex} /> @@ -387,6 +396,8 @@ const TextField: VibeComponent & { {showCharCount && ( {(inputValue && inputValue.length) || 0} + {typeof maxLength === "number" && `/${maxLength}`} + )} diff --git a/packages/core/src/components/TextField/__tests__/TextField.test.js b/packages/core/src/components/TextField/__tests__/TextField.test.js index be0f7b44bf..fdf112e444 100644 --- a/packages/core/src/components/TextField/__tests__/TextField.test.js +++ b/packages/core/src/components/TextField/__tests__/TextField.test.js @@ -151,7 +151,7 @@ describe("TextField Tests", () => { expect(icon).toBeFalsy(); }); - describe("char count", () => { + describe("char count and limit", () => { it("should display char count on initial", () => { const { rerender, queryByLabelText } = inputComponent; const value = "hello"; @@ -171,6 +171,25 @@ describe("TextField Tests", () => { expect(parseInt(charCount.innerHTML, 10)).toBe(value.length); }); + it("should display char count and max length on initial", () => { + const { rerender, getByText } = inputComponent; + const value = "hello"; + act(() => { + rerender( + + ); + }); + + expect(getByText(`${value.length}/10`)).toBeTruthy(); + }); + it("char count should display correctly after changing value", () => { const { rerender, queryByLabelText } = inputComponent; let value = "hello"; @@ -195,6 +214,40 @@ describe("TextField Tests", () => { const charCount = queryByLabelText(TextFieldAriaLabel.CHAR); expect(parseInt(charCount.innerHTML, 10)).toEqual(value.length); }); + + it("should prevent typing when character limit is reached and allowExceedingMaxLength is false", () => { + const { rerender } = inputComponent; + act(() => { + rerender( + + ); + }); + + const input = screen.getByPlaceholderText(defaultPlaceHolder); + // This correctly tests the maxLength attribute be properly set on the input element + // Using fireEvent bypasses the maxLength where a user wouldn't: + // https://github.com/testing-library/user-event/issues/591#issuecomment-517816296 + expect(input).toHaveAttribute("maxlength", "5"); + }); + + it("should allow typing beyond character limit when allowExceedingMaxLength is true", () => { + const { rerender, getByText } = inputComponent; + act(() => { + rerender( + + ); + }); + + const input = screen.getByPlaceholderText(defaultPlaceHolder); + act(() => { + fireEvent.change(input, { target: { value: "123456" } }); + }); + + expect(input).toHaveValue("123456"); + expect(input).not.toHaveAttribute("maxlength"); + + expect(getByText("6/5")).toBeTruthy(); + }); }); describe("validation text", () => { diff --git a/packages/core/src/components/TextField/__tests__/__snapshots__/TextField.snapshot.test.tsx.snap b/packages/core/src/components/TextField/__tests__/__snapshots__/TextField.snapshot.test.tsx.snap index 47a42a4008..18d87d5f3e 100644 --- a/packages/core/src/components/TextField/__tests__/__snapshots__/TextField.snapshot.test.tsx.snap +++ b/packages/core/src/components/TextField/__tests__/__snapshots__/TextField.snapshot.test.tsx.snap @@ -23,7 +23,6 @@ exports[`TextField renders correctly when disabled 1`] = ` data-testid="text-field_input" disabled={true} id="input" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -86,7 +85,6 @@ exports[`TextField renders correctly when loading 1`] = ` data-testid="text-field_input" disabled={false} id="input" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -178,7 +176,6 @@ exports[`TextField renders correctly when readonly 1`] = ` data-testid="text-field_input" disabled={false} id="input" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -241,7 +238,6 @@ exports[`TextField renders correctly when required 1`] = ` data-testid="text-field_input" disabled={false} id="input" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -304,7 +300,6 @@ exports[`TextField renders correctly with another type 1`] = ` data-testid="text-field_input" disabled={false} id="input" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -367,7 +362,6 @@ exports[`TextField renders correctly with className 1`] = ` data-testid="text-field_input" disabled={false} id="input" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -430,7 +424,6 @@ exports[`TextField renders correctly with className 2`] = ` data-testid="text-field_input" disabled={false} id="input" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -493,7 +486,6 @@ exports[`TextField renders correctly with date type 1`] = ` data-testid="text-field_input" disabled={false} id="input" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -556,7 +548,6 @@ exports[`TextField renders correctly with date-time type 1`] = ` data-testid="text-field_input" disabled={false} id="input" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -619,7 +610,6 @@ exports[`TextField renders correctly with email type 1`] = ` data-testid="text-field_input" disabled={false} id="input" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -682,7 +672,6 @@ exports[`TextField renders correctly with icon 1`] = ` data-testid="text-field_input" disabled={false} id="input" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -753,7 +742,6 @@ exports[`TextField renders correctly with iconsNames 1`] = ` data-testid="text-field_input" disabled={false} id="input" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -818,7 +806,6 @@ exports[`TextField renders correctly with id 1`] = ` data-testid="text-field_testId" disabled={false} id="testId" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -881,7 +868,6 @@ exports[`TextField renders correctly with labelIconName 1`] = ` data-testid="text-field_input" disabled={false} id="input" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -944,7 +930,6 @@ exports[`TextField renders correctly with large size 1`] = ` data-testid="text-field_input" disabled={false} id="input" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -1007,7 +992,6 @@ exports[`TextField renders correctly with placeholder 1`] = ` data-testid="text-field_input" disabled={false} id="input" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -1070,7 +1054,6 @@ exports[`TextField renders correctly with role 1`] = ` data-testid="text-field_input" disabled={false} id="input" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -1133,7 +1116,6 @@ exports[`TextField renders correctly with secondaryIconName 1`] = ` data-testid="text-field_input" disabled={false} id="input" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -1204,7 +1186,6 @@ exports[`TextField renders correctly with showCharCount 1`] = ` data-testid="text-field_input" disabled={false} id="input" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -1249,6 +1230,13 @@ exports[`TextField renders correctly with showCharCount 1`] = ` className="counter" > 0 + + Maximum of null characters + @@ -1278,7 +1266,6 @@ exports[`TextField renders correctly with tel type 1`] = ` data-testid="text-field_input" disabled={false} id="input" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -1341,7 +1328,6 @@ exports[`TextField renders correctly with url type 1`] = ` data-testid="text-field_input" disabled={false} id="input" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -1404,7 +1390,6 @@ exports[`TextField renders correctly with validation 1`] = ` data-testid="text-field_input" disabled={false} id="input" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -1477,7 +1462,6 @@ exports[`TextField renders correctly with value 1`] = ` data-testid="text-field_input" disabled={false} id="input" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]} @@ -1540,7 +1524,6 @@ exports[`TextField renders correctly with wrapperClassName 1`] = ` data-testid="text-field_input" disabled={false} id="input" - maxLength={null} onBlur={[Function]} onChange={[Function]} onFocus={[Function]}