Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(TextareaField): support recommendedMaxLength prop for display-only errors #1769

Merged
merged 1 commit into from
Oct 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions src/components/TextareaField/TextareaField.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,6 @@ export const WhenError: Story = {
},
};

export const WhenInvalid: Story = {
args: {
maxLength: 10,
},
};

export const WhenRequired: Story = {
args: {
required: true,
Expand All @@ -92,3 +86,22 @@ export const WithAMaxLength: Story = {
},
render: (args) => <TextareaField {...args} />,
};

export const WithARecommendedLength: Story = {
args: {
rows: 10,
recommendedMaxLength: 144,
required: true,
},
render: (args) => <TextareaField {...args} />,
};

export const WithBothRecommendedAndMaxLengths: Story = {
args: {
rows: 10,
maxLength: 256,
recommendedMaxLength: 144,
required: true,
},
render: (args) => <TextareaField {...args} />,
};
54 changes: 53 additions & 1 deletion src/components/TextareaField/TextareaField.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { generateSnapshots } from '@chanzuckerberg/story-utils';
import type { StoryFile } from '@storybook/testing-react';

import { render, screen } from '@testing-library/react';
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import React from 'react';
Expand Down Expand Up @@ -50,4 +50,56 @@ describe('<TextareaField />', () => {

expect(onChangeFn).not.toHaveBeenCalled();
});

it('will not fire a custom event when at max length', async () => {
const onChangeFn = jest.fn();
const user = userEvent.setup();
render(
<TextareaField
aria-label="test"
defaultValue="1234567890"
maxLength={10}
onChange={() => onChangeFn()}
/>,
);

const field = screen.getByRole('textbox');

expect(field).not.toHaveFocus();

await userEvent.tab();

expect(field).toHaveFocus();

await user.keyboard('abc');

expect(onChangeFn).not.toHaveBeenCalled();
});

it('will fire a custom event when at max recommended length', async () => {
const onChangeFn = jest.fn();
const user = userEvent.setup();
render(
<TextareaField
aria-label="test"
defaultValue="1234567890"
onChange={() => onChangeFn()}
recommendedMaxLength={10}
/>,
);

const field = screen.getByRole('textbox');

expect(field).not.toHaveFocus();

await userEvent.tab();

expect(field).toHaveFocus();

await act(async () => {
await user.keyboard('abc');
});

expect(onChangeFn).toHaveBeenCalled();
});
});
42 changes: 36 additions & 6 deletions src/components/TextareaField/TextareaField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ type TextareaFieldProps = React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
* Error state of the form field
*/
isError?: boolean;
/**
* Behaves similar to `maxLength` but allows the user to continue typing more text.
* Should not be larger than `maxLength`, if present.
*/
recommendedMaxLength?: number;
} & EitherInclusive<
{
/**
Expand Down Expand Up @@ -111,6 +116,21 @@ const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
},
);

/**
* Given two lengths that may not be defined, return the smaller of the defined lengths
* TODO-AH: make this take a rest and can operate on any numbe of values
* TODO-AH: move this to utilities
*/
function getSmallest(lengthA?: number, lengthB?: number): number | undefined {
if (lengthA !== undefined && lengthB !== undefined) {
return Math.min(lengthA, lengthB);
} else if (lengthA && lengthB === undefined) {
return lengthA;
} else if (lengthB && lengthA === undefined) {
return lengthB;
}
}
Comment on lines +124 to +132
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code golf time 🏌️‍♀️

Suggested change
function getSmallest(lengthA?: number, lengthB?: number): number | undefined {
if (lengthA !== undefined && lengthB !== undefined) {
return Math.min(lengthA, lengthB);
} else if (lengthA && lengthB === undefined) {
return lengthA;
} else if (lengthB && lengthA === undefined) {
return lengthB;
}
}
function getSmallest(...args: Array<number | undefined>): number | undefined {
if (args.length === 0) {
return;
}
return Math.min(...args.filter(Boolean) as number[]);
}

or with type predicate on the filter:

Suggested change
function getSmallest(lengthA?: number, lengthB?: number): number | undefined {
if (lengthA !== undefined && lengthB !== undefined) {
return Math.min(lengthA, lengthB);
} else if (lengthA && lengthB === undefined) {
return lengthA;
} else if (lengthB && lengthA === undefined) {
return lengthB;
}
}
function getSmallest(...args: Array<number | undefined>): number | undefined {
if (args.length === 0) {
return;
}
return Math.min(...args.filter((n): n is number => Boolean(n)));
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like these ⛳! Since the other PR builds upon this one and moves this into a utility, will commit this as-is, and take some strokes off in #1771


/**
* `import {TextareaField} from "@chanzuckerberg/eds";`
*
Expand All @@ -134,6 +154,7 @@ export const TextareaField: TextareaFieldType = forwardRef(
label,
maxLength,
onChange,
recommendedMaxLength,
required,
...other
},
Expand All @@ -146,10 +167,16 @@ export const TextareaField: TextareaFieldType = forwardRef(
const idVar = id || generatedIdVar;
const shouldRenderOverline = !!(label || required);
const fieldLength = fieldText?.toString().length;
const textExceedsLength =
const textExceedsMaxLength =
maxLength !== undefined ? fieldLength > maxLength : false;

const shouldRenderError = isError || textExceedsLength;
const textExceedsRecommendedLength =
recommendedMaxLength !== undefined
? fieldLength > recommendedMaxLength
: false;

const shouldRenderError =
isError || textExceedsMaxLength || textExceedsRecommendedLength;

const ariaDescribedByVar = fieldNote
? ariaDescribedBy || generatedAriaDescribedById
Expand All @@ -171,9 +198,12 @@ export const TextareaField: TextareaFieldType = forwardRef(
disabled && styles['textarea-field__required-text--disabled'],
);
const fieldLengthCountClassName = clsx(
textExceedsLength && styles['textarea-field--invalid-length'],
(textExceedsMaxLength || textExceedsRecommendedLength) &&
styles['textarea-field--invalid-length'],
);

const maxLengthShown = getSmallest(maxLength, recommendedMaxLength);

return (
<div className={componentClassName}>
{shouldRenderOverline && (
Expand Down Expand Up @@ -209,7 +239,7 @@ export const TextareaField: TextareaFieldType = forwardRef(
>
{children}
</TextArea>
{(fieldNote || maxLength) && (
{(fieldNote || maxLengthShown) && (
<div className={styles['textarea-field__footer']}>
{fieldNote && (
<FieldNote
Expand All @@ -221,10 +251,10 @@ export const TextareaField: TextareaFieldType = forwardRef(
{fieldNote}
</FieldNote>
)}
{maxLength && (
{maxLengthShown && (
<div className={styles['textarea-field__character-counter']}>
<span className={fieldLengthCountClassName}>{fieldLength}</span>{' '}
/ {maxLength}
/ {maxLengthShown}
</div>
)}
</div>
Expand Down
Loading
Loading