Skip to content

Commit

Permalink
feat(InputField): support recommendedMaxLength prop for display-only …
Browse files Browse the repository at this point in the history
…errors

- allow handling for maxlength and recommendedMaxLength like
  TextareaField
- add utility method for finding smallest value
- update snapshots and tests
  • Loading branch information
booc0mtaco committed Oct 3, 2023
1 parent 0852356 commit 612bad2
Show file tree
Hide file tree
Showing 9 changed files with 391 additions and 28 deletions.
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);
});

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;

const textExceedsMaxLength =
maxLength !== undefined && fieldLength ? fieldLength > maxLength : false;

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);

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

0 comments on commit 612bad2

Please sign in to comment.