Skip to content

Commit

Permalink
feat(TextField): improved maxLength with exceeding limit UI (#2576)
Browse files Browse the repository at this point in the history
  • Loading branch information
feclist authored Nov 15, 2024
1 parent 7779bd4 commit 291a843
Show file tree
Hide file tree
Showing 5 changed files with 79 additions and 41 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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]}
Expand Down Expand Up @@ -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]}
Expand Down Expand Up @@ -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]}
Expand Down Expand Up @@ -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]}
Expand Down Expand Up @@ -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]}
Expand Down Expand Up @@ -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]}
Expand Down Expand Up @@ -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]}
Expand Down Expand Up @@ -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]}
Expand Down Expand Up @@ -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]}
Expand Down Expand Up @@ -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]}
Expand Down Expand Up @@ -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]}
Expand Down Expand Up @@ -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]}
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/components/TextField/TextField.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 13 additions & 2 deletions packages/core/src/components/TextField/TextField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: "" };

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -146,6 +148,7 @@ const TextField: VibeComponent<TextFieldProps, unknown> & {
iconsNames = EMPTY_OBJECT,
type = TextFieldTextType.TEXT,
maxLength = null,
allowExceedingMaxLength = false,
trim = false,
role = "",
required = false,
Expand Down Expand Up @@ -241,12 +244,16 @@ const TextField: VibeComponent<TextFieldProps, unknown> & {
}, [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;
Expand All @@ -255,6 +262,7 @@ const TextField: VibeComponent<TextFieldProps, unknown> & {
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) {
Expand Down Expand Up @@ -306,13 +314,14 @@ const TextField: VibeComponent<TextFieldProps, unknown> & {
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}
/>
Expand Down Expand Up @@ -387,6 +396,8 @@ const TextField: VibeComponent<TextFieldProps, unknown> & {
{showCharCount && (
<span className={cx(styles.counter)} aria-label={TextFieldAriaLabel.CHAR}>
{(inputValue && inputValue.length) || 0}
{typeof maxLength === "number" && `/${maxLength}`}
<HiddenText id={allowExceedingMaxLengthTextId} text={`Maximum of ${maxLength} characters`} />
</span>
)}
</Text>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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(
<TextField
placeholder={defaultPlaceHolder}
onChange={onChangeStub}
id="char-count-test"
showCharCount
value={value}
maxLength={10}
/>
);
});

expect(getByText(`${value.length}/10`)).toBeTruthy();
});

it("char count should display correctly after changing value", () => {
const { rerender, queryByLabelText } = inputComponent;
let value = "hello";
Expand All @@ -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(
<TextField placeholder={defaultPlaceHolder} showCharCount maxLength={5} allowExceedingMaxLength={false} />
);
});

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(
<TextField placeholder={defaultPlaceHolder} showCharCount maxLength={5} allowExceedingMaxLength={true} />
);
});

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", () => {
Expand Down
Loading

0 comments on commit 291a843

Please sign in to comment.