Skip to content

Commit

Permalink
feat(TextareaField): add character length counter (#1580)
Browse files Browse the repository at this point in the history
- only shows up if max length is specified
- turns red if the default value is greater than allowed
- updates as you type
- currently no warning color as you approach limit
- by default textareas do not allow entry above maxlength
- but you can delete if somehow above maxlength
- handle custom onChange events
  • Loading branch information
booc0mtaco authored Apr 4, 2023
1 parent b581814 commit ff6226f
Show file tree
Hide file tree
Showing 4 changed files with 315 additions and 64 deletions.
23 changes: 23 additions & 0 deletions src/components/TextareaField/TextareaField.module.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@import '../../design-tokens/mixins.css';

/*------------------------------------*\
# TEXTAREA FIELD
\*------------------------------------*/
Expand All @@ -19,3 +21,24 @@
.textarea-field__overline--disabled {
color: var(--eds-theme-color-text-disabled);
}

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

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

.textarea-field__field-note {
flex: 1 0 50%;
}

.textarea-field__character-counter {
@mixin eds-theme-typography-body-text-sm;

color: var(--eds-theme-color-text-neutral-default);
flex: 1 0 50%;
text-align: right;
}
24 changes: 23 additions & 1 deletion src/components/TextareaField/TextareaField.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { StoryObj, Meta } from '@storybook/react';
import { userEvent } from '@storybook/testing-library';
import React from 'react';

import { TextareaField } from './TextareaField';
Expand Down Expand Up @@ -39,7 +40,8 @@ export const UsingChildren: StoryObj<Args> = {

export const WithNoDefaultValue: StoryObj<Args> = {
args: {
defaultValue: '',
defaultValue: undefined,
fieldNote: undefined,
},
};

Expand Down Expand Up @@ -80,3 +82,23 @@ export const WithADifferentSize: StoryObj<Args> = {
rows: 10,
},
};

export const WithAMaxLength: StoryObj<Args> = {
args: {
rows: 10,
maxLength: 144,
required: true,
},
render: (args) => <TextareaField {...args} />,
};

export const AfterDelete: StoryObj<Args> = {
args: {
maxLength: 209,
onChange: () => console.info('ensure custom events are handled'),
},
play: () => {
userEvent.tab();
userEvent.keyboard('{arrowdown}{delete}');
},
};
65 changes: 47 additions & 18 deletions src/components/TextareaField/TextareaField.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import clsx from 'clsx';
import type { ReactNode } from 'react';
import React, { forwardRef, useId } from 'react';
import React, { forwardRef, useState, useId } from 'react';
import type { EitherInclusive } from '../../util/utility-types';
import FieldNote from '../FieldNote';
import Label from '../Label';
Expand Down Expand Up @@ -63,32 +63,44 @@ export const TextareaField = forwardRef<HTMLTextAreaElement, Props>(
'aria-describedby': ariaDescribedBy,
children,
className,
defaultValue = '',
disabled,
fieldNote,
id,
isError,
label,
maxLength,
onChange,
required,
...other
},
ref,
) => {
const shouldRenderOverline = !!(label || required);
const overlineClassName = clsx(
styles['textarea-field__overline'],
!label && styles['textarea-field__overline--no-label'],
disabled && styles['textarea-field__overline--disabled'],
);

const [fieldText, setFieldText] = useState(defaultValue);
const generatedIdVar = useId();
const generatedAriaDescribedById = useId();

const idVar = id || generatedIdVar;
const shouldRenderOverline = !!(label || required);
const fieldLength = fieldText?.toString().length;
const textExceedsLength =
maxLength !== undefined ? fieldLength > maxLength : false;

const shouldRenderError = isError || textExceedsLength;

const generatedAriaDescribedById = useId();
const ariaDescribedByVar = fieldNote
? ariaDescribedBy || generatedAriaDescribedById
: undefined;

const componentClassName = clsx(styles['textarea-field'], className);
const overlineClassName = clsx(
styles['textarea-field__overline'],
!label && styles['textarea-field__overline--no-label'],
disabled && styles['textarea-field__overline--disabled'],
);
const fieldLengthCountClassName = clsx(
textExceedsLength && styles['textarea-field--invalid-length'],
);

return (
<div className={componentClassName}>
Expand All @@ -105,24 +117,41 @@ export const TextareaField = forwardRef<HTMLTextAreaElement, Props>(
<TextArea
aria-describedby={ariaDescribedByVar}
aria-disabled={disabled}
defaultValue={defaultValue}
disabled={disabled}
id={idVar}
isError={isError}
isError={shouldRenderError}
maxLength={maxLength}
onChange={(e) => {
setFieldText(e.target.value);
onChange && onChange(e);
}}
readOnly={disabled}
ref={ref}
required={required}
{...other}
>
{children}
</TextArea>
{fieldNote && (
<FieldNote
disabled={disabled}
id={ariaDescribedByVar}
isError={isError}
>
{fieldNote}
</FieldNote>
{(fieldNote || maxLength) && (
<div className={styles['textarea-field__footer']}>
{fieldNote && (
<FieldNote
className={styles['textarea-field__field-note']}
disabled={disabled}
id={ariaDescribedByVar}
isError={shouldRenderError}
>
{fieldNote}
</FieldNote>
)}
{maxLength && (
<div className={styles['textarea-field__character-counter']}>
<span className={fieldLengthCountClassName}>{fieldLength}</span>{' '}
/ {maxLength}
</div>
)}
</div>
)}
</div>
);
Expand Down
Loading

0 comments on commit ff6226f

Please sign in to comment.