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(InputField): support recommendedMaxLength prop for display-only errors #1771

Merged
merged 2 commits 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
2 changes: 0 additions & 2 deletions src/components/Badge/Badge.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,6 @@ export const IconBadgeUsingIcon: StoryObj<Args> = {
},
};

// TODO-AH: add in test to see if error is thrown

export const LargeBadgeableObject: StoryObj<Args> = {
args: {
children: (
Expand Down
19 changes: 19 additions & 0 deletions src/components/InputField/InputField.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
color: var(--eds-theme-color-form-label);
font: var(--eds-theme-typography-form-label);
}

.input-field__label--disabled {
color: var(--eds-theme-color-text-disabled);
}
Expand All @@ -33,14 +34,32 @@
color: var(--eds-theme-color-text-neutral-subtle);
font: var(--eds-theme-typography-body-sm);
}

.input-field__required-text--disabled {
color: var(--eds-theme-color-text-disabled);
}

.input-field__footer {
display: flex;
justify-content: space-between;
}

.input-field__character-counter {
font: var(--eds-theme-typography-body-sm);

color: var(--eds-theme-color-text-neutral-default);
flex: 1 0 50%;
text-align: right;
}

.input-field--has-fieldNote {
margin-bottom: var(--eds-size-half);
}

.input-field--invalid-length {
color: var(--eds-theme-color-text-utility-error);
}

/**
 * Input Field Within
*
Expand Down
32 changes: 32 additions & 0 deletions src/components/InputField/InputField.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,38 @@ export const RequiredVariants: Story = {
},
};

export const WithAMaxLength: Story = {
args: {
defaultValue: 'Some initial text',
label: 'test label',
maxLength: 15,
required: true,
},
render: (args) => <InputField {...args} />,
};

export const WithARecommendedLength: Story = {
args: {
defaultValue: 'Some initial text',
label: 'test label',
recommendedMaxLength: 15,
required: true,
},
render: (args) => <InputField {...args} />,
};

export const WithBothMaxAndRecommendedLength: Story = {
args: {
label: 'test label',
defaultValue: 'Some initial text',
fieldNote: 'Longer Field Description',
maxLength: 20,
recommendedMaxLength: 15,
required: true,
},
render: (args) => <InputField {...args} />,
};

export const TabularInput: Story = {
parameters: {
badges: ['1.1', 'implementationExample'],
Expand Down
50 changes: 50 additions & 0 deletions src/components/InputField/InputField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,54 @@ describe('<InputField />', () => {

expect(onChange).toHaveBeenCalledTimes(testText.length);
});

it('will not fire when maxLength is reached', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
const testText = 'typing';

render(
<InputField
aria-label="label"
data-testid="test-input"
defaultValue={testText}
maxLength={6}
onChange={onChange}
/>,
);
const input = screen.getByTestId('test-input');

input.focus();

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

expect(onChange).toHaveBeenCalledTimes(0);
});

it('will fire when recommendedMaxLength is reached', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
const testText = 'typing';

render(
<InputField
aria-label="label"
data-testid="test-input"
defaultValue={testText}
onChange={onChange}
recommendedMaxLength={6}
/>,
);
const input = screen.getByTestId('test-input');

input.focus();

await act(async () => {
await user.keyboard(testText);
});
booc0mtaco marked this conversation as resolved.
Show resolved Hide resolved

expect(onChange).toHaveBeenCalledTimes(testText.length);
});
});
80 changes: 70 additions & 10 deletions src/components/InputField/InputField.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import clsx from 'clsx';
import type { ChangeEventHandler, ReactNode } from 'react';
import React, { forwardRef } from 'react';
import React, { forwardRef, useState } from 'react';
import { getMinValue } from '../../util/getMinValue';
import { useId } from '../../util/useId';
import type {
EitherInclusive,
Expand Down Expand Up @@ -73,6 +74,11 @@ export type Props = React.InputHTMLAttributes<HTMLInputElement> & {
* Toggles the form control's interactivity. When `readOnly` is set to `true`, the form control is not interactive
*/
readOnly?: boolean;
/**
* Behaves similar to `maxLength` but allows the user to continue typing more text.
* Should not be larger than `maxLength`, if present.
*/
recommendedMaxLength?: number;
/**
* Indicates that field is required for form to be successfully submitted
*/
Expand Down Expand Up @@ -148,13 +154,18 @@ export const InputField: InputFieldType = forwardRef(
inputWithin,
isError,
label,
maxLength,
onChange,
recommendedMaxLength,
required,
type = 'text',
...other
},
ref,
) => {
const shouldRenderOverline = !!(label || required);
const [fieldText, setFieldText] = useState(other.defaultValue);

const overlineClassName = clsx(
styles['input-field__overline'],
!label && styles['input-field__overline--no-label'],
Expand All @@ -175,6 +186,19 @@ export const InputField: InputFieldType = forwardRef(
fieldNote && styles['input-field--has-fieldNote'],
);

const fieldLength = fieldText?.toString().length ?? 0;

const textExceedsMaxLength =
maxLength !== undefined && fieldLength ? fieldLength > maxLength : false;
Comment on lines +191 to +192
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit, couple ideas:

const fieldLength = fieldText?.toString().length ?? 0;
const textExceedsMaxLength = maxLength && fieldLength > maxLength;

or give maxLength a default and simplifyt the check even more

maxLength = Infinity

...

const textExceedsMaxLength = fieldLength > maxLength;

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Will apply a few things where possible. One thing I wanted to avoid were any props being added if the consumer didn't specify them. maxLength is also weird in that it changes how the base field behaves when it has a value, and it treats Infinity as invalid if it gets to <textarea>


const textExceedsRecommendedLength =
recommendedMaxLength !== undefined && fieldLength
? fieldLength > recommendedMaxLength
: false;

const shouldRenderError =
isError || textExceedsMaxLength || textExceedsRecommendedLength;

const generatedIdVar = useId();
const idVar = id || generatedIdVar;

Expand All @@ -183,6 +207,14 @@ export const InputField: InputFieldType = forwardRef(
? ariaDescribedBy || generatedAriaDescribedById
: undefined;

const fieldLengthCountClassName = clsx(
(textExceedsMaxLength || textExceedsRecommendedLength) &&
styles['input-field--invalid-length'],
);

// Pick the smallest of the lengths to set as the maximum value allowed
const maxLengthShown = getMinValue(maxLength, recommendedMaxLength);
Copy link
Contributor

Choose a reason for hiding this comment

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

If maxLength and recommendedMaxLength are numbers (e.g. if they have default values or fallbacks), may be able to use Math.min?


return (
<div className={className}>
{shouldRenderOverline && (
Expand All @@ -206,7 +238,12 @@ export const InputField: InputFieldType = forwardRef(
aria-invalid={!!isError}
disabled={disabled}
id={idVar}
isError={isError}
isError={shouldRenderError}
maxLength={maxLength}
onChange={(e) => {
setFieldText(e.target.value);
onChange && onChange(e);
}}
ref={ref}
required={required}
type={type}
Expand All @@ -218,14 +255,37 @@ export const InputField: InputFieldType = forwardRef(
</div>
)}
</div>
{fieldNote && (
<FieldNote
disabled={disabled}
id={ariaDescribedByVar}
isError={isError}
>
{fieldNote}
</FieldNote>
{maxLengthShown ? (
<div className={styles['input-field__footer']}>
{fieldNote && (
<FieldNote
disabled={disabled}
id={ariaDescribedByVar}
isError={shouldRenderError}
>
{fieldNote}
</FieldNote>
)}
{maxLengthShown && (
<div className={styles['input-field__character-counter']}>
<span className={fieldLengthCountClassName}>{fieldLength}</span>{' '}
/ {maxLengthShown}
</div>
)}
</div>
) : (
<>
{/* maintained for seamless upgrades; can be removed on next breaking change */}
{fieldNote && (
<FieldNote
disabled={disabled}
id={ariaDescribedByVar}
isError={shouldRenderError}
>
{fieldNote}
</FieldNote>
)}
</>
)}
</div>
);
Expand Down
Loading
Loading