diff --git a/src/components/InputField/InputField-v2.tsx b/src/components/InputField/InputField-v2.tsx index b195c4934..610b4498e 100644 --- a/src/components/InputField/InputField-v2.tsx +++ b/src/components/InputField/InputField-v2.tsx @@ -174,6 +174,7 @@ export const InputField: InputFieldType = forwardRef( leadingIcon, maxLength, onChange, + readOnly, recommendedMaxLength, required, showHint, @@ -289,6 +290,7 @@ export const InputField: InputFieldType = forwardRef( setFieldText(e.target.value); onChange && onChange(e); }} + readOnly={readOnly} ref={ref} required={required} type={type} diff --git a/src/components/TextareaField/TextareaField-v2.module.css b/src/components/TextareaField/TextareaField-v2.module.css new file mode 100644 index 000000000..f5add8fb1 --- /dev/null +++ b/src/components/TextareaField/TextareaField-v2.module.css @@ -0,0 +1,53 @@ +@import '../../design-tokens/mixins.css'; + +/*------------------------------------*\ + # TEXTAREA FIELD +\*------------------------------------*/ + +/** + * Default input styles + */ +.textarea { + @mixin inputStylesV2; +} + +/** + * Wraps the Label and the optional/required indicator. + */ +.textarea-field__overline { + display: flex; + margin-bottom: 0.25rem; + gap: 0.25rem; +} + +.textarea-field__overline--no-label { + justify-content: flex-start; +} + +.textarea-field__label { + font: var(--eds-theme-typography-form-label); +} + +.textarea-field__label--disabled { + color: var(--eds-theme-color-text-utility-disabled-primary); +} + +.textarea-field--invalid-length { + color: var(--eds-theme-color-text-utility-critical); +} + +.textarea-field__footer { + display: flex; + justify-content: space-between; +} + +.textarea-field__field-note { + flex: 1 0 50%; +} + +.textarea-field__character-counter { + font: var(--eds-theme-typography-body-sm); + + flex: 1 0 50%; + text-align: right; +} diff --git a/src/components/TextareaField/TextareaField-v2.stories.tsx b/src/components/TextareaField/TextareaField-v2.stories.tsx new file mode 100644 index 000000000..8ada9b0f5 --- /dev/null +++ b/src/components/TextareaField/TextareaField-v2.stories.tsx @@ -0,0 +1,161 @@ +import type { StoryObj, Meta } from '@storybook/react'; +import React from 'react'; + +import { TextareaField } from './TextareaField-v2'; + +const meta: Meta = { + title: 'Components/V2/TextareaField', + component: TextareaField, + args: { + placeholder: 'Enter long-form text here', + defaultValue: `Lorem ipsum, dolor sit amet consectetur adipisicing elit. Id neque nemo +dicta rerum commodi et fugiat quo optio veniam! Ea odio corporis nemo +praesentium, commodi eligendi asperiores quis dolorum porro.`, + label: 'Textarea Field', + rows: 5, + fieldNote: 'Longer Field description', + spellCheck: false, + }, + parameters: { + badges: ['intro-1.3', 'current-2.0'], + }, + decorators: [(Story) =>
{Story()}
], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ( + + ), +}; + +/** + * You can specify the content of `TextareaField` by using children. Convenient for cases where + * specifying `value` or `defaultValue` is inconvenient. + */ +export const WhenUsingChildren: Story = { + args: { + children: `Lorem ipsum, dolor sit amet consectetur adipisicing elit. Id neque nemo + dicta rerum commodi et fugiat quo optio veniam! Ea odio corporis nemo + praesentium, commodi eligendi asperiores quis dolorum porro.`, + defaultValue: '', + }, +}; + +/** + * `TextareaField` does not require any initial content. + */ +export const WhenNoDefaultValue: Story = { + args: { + defaultValue: undefined, + fieldNote: undefined, + }, +}; + +export const WhenDisabled: Story = { + args: { + disabled: true, + rows: 2, + }, + parameters: { + axe: { + // Disabled input does not need to meet color contrast + disabledRules: ['color-contrast'], + }, + }, +}; + +export const WhenReadOnly: Story = { + args: { + readOnly: true, + rows: 2, + }, + parameters: { + axe: { + // Disabled input does not need to meet color contrast + disabledRules: ['color-contrast'], + }, + }, +}; + +export const WhenError: Story = { + args: { + isError: true, + fieldNote: 'Text should be at least 100 characters', + }, +}; + +export const WhenWarning: Story = { + args: { + isWarning: true, + fieldNote: 'Text should be at least 100 characters', + }, +}; + +export const WhenRequired: Story = { + args: { + required: true, + showHint: true, + }, +}; + +export const WhenOptional: Story = { + args: { + required: false, + showHint: true, + }, +}; + +/** + * You can size `TextareaField` by specifying `row` attribute, inherited from + * [textarea](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea). + */ +export const WithADifferentSize: Story = { + args: { + rows: 10, + }, +}; + +/** + * You can lock the maximum length of the text content of `TextareaField`. When setting `maxLength`, + * the field will reuse the browser's [textarea](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea) + * behavior (e.g., prevent further text from being typed, prevent keydown events, etc.). + */ +export const WithAMaxLength: Story = { + args: { + rows: 10, + maxLength: 144, + required: true, + }, + render: (args) => , +}; + +/** + * If you want to signal that a field has reached a maximum length but want to allow more text to be typed, you can use + * `recommendedMaxLength`. This will show a similar UI to using `maxLength` but will allow more text to be typed, and + * emit any appropriate events. + */ +export const WithARecommendedLength: Story = { + args: { + rows: 10, + recommendedMaxLength: 144, + required: true, + }, + render: (args) => , +}; + +/** + * Both `maxLength` and `recommendedMaxLength` can be specified at the same time. Text length between `recommendedMaxLength` + * and `maxLength` will show the treatment warning the user about the text length being violated. + */ +export const WithBothRecommendedAndMaxLengths: Story = { + args: { + rows: 10, + maxLength: 256, + recommendedMaxLength: 144, + required: true, + }, + render: (args) => , +}; diff --git a/src/components/TextareaField/TextareaField-v2.tsx b/src/components/TextareaField/TextareaField-v2.tsx new file mode 100644 index 000000000..3e5c66c67 --- /dev/null +++ b/src/components/TextareaField/TextareaField-v2.tsx @@ -0,0 +1,302 @@ +import clsx from 'clsx'; +import type { ReactNode } from 'react'; +import React, { forwardRef, useState } from 'react'; +import { getMinValue } from '../../util/getMinValue'; +import { useId } from '../../util/useId'; +import type { + EitherInclusive, + ForwardedRefComponent, +} from '../../util/utility-types'; + +import { FieldNoteV2 as FieldNote } from '../FieldNote'; +import { InputLabelV2 as InputLabel } from '../InputLabel'; +import Text from '../Text'; + +import styles from './TextareaField-v2.module.css'; + +type TextareaFieldProps = React.TextareaHTMLAttributes & { + // Component API + /** + * Text content of the field upon instantiation + */ + children?: string; + /** + * CSS class names that can be appended to the component. + */ + className?: string; + /** + * Disables the field and prevents editing the contents + */ + disabled?: boolean; + /** + * Text under the textarea used to provide a description or error message to describe the input. + */ + fieldNote?: ReactNode; + /** + * HTML id for the component. Can be used with a custom Label component + */ + id?: string; + // Design API + /** + * Error state of the form field + */ + isError?: boolean; + /** + * Whether there is a warning state for the field note text (and icon) + * + * **Default is `false`**. + */ + isWarning?: boolean; + /** + * Behaves similar to `maxLength` but allows the user to continue typing more text. + * Should not be larger than `maxLength`, if present. + */ + recommendedMaxLength?: number; + /** + * Whether it should show the field hint or not + * + * **Default is `"false"`**. + */ + showHint?: boolean; +} & EitherInclusive< + { + /** + * Visible text label for the component. + */ + label: string; + }, + { + /** + * Aria-label to provide an accesible name for the text input if no visible label is provided. + */ + 'aria-label': string; + } + >; + +type TextareaFieldType = ForwardedRefComponent< + HTMLTextAreaElement, + TextareaFieldProps +> & { + TextArea?: typeof TextArea; + Label?: typeof InputLabel; +}; + +type TextAreaProps = React.TextareaHTMLAttributes & { + /** + * CSS class names that can be appended to the component + */ + className?: string; + /** + * Text default contents of the field + */ + children?: string; + /** + * Whether the disabled stat is active + */ + disabled?: boolean; + /** + * Whether the error state is active + */ + isError?: boolean; + /** + * Whether there is a warning state for the field note text (and icon) + * + * **Default is `false`**. + */ + isWarning?: boolean; +}; + +/** + * Base component, applying styles to a + ); + }, +); + +/** + * `import {TextareaField} from "@chanzuckerberg/eds";` + * + * Multi-line text input field with built-in labeling and accessory text to describe + * the content. When a maximum text count is specified, component also shows relevant + * text up to the maximum. + * + * **NOTE**: This component requires `label` or `aria-label` prop to support accessibility. + */ +export const TextareaField: TextareaFieldType = forwardRef( + ( + { + 'aria-describedby': ariaDescribedBy, + children, + className, + defaultValue = '', + disabled, + fieldNote, + id, + isError, + isWarning, + label, + maxLength, + onChange, + readOnly, + recommendedMaxLength, + required, + showHint, + ...other + }, + ref, + ) => { + const [fieldText, setFieldText] = useState(defaultValue); + const generatedIdVar = useId(); + const generatedAriaDescribedById = useId(); + + const idVar = id || generatedIdVar; + const shouldRenderOverline = !!(label || required); + const fieldLength = fieldText?.toString().length ?? 0; + const textExceedsMaxLength = + maxLength !== undefined ? fieldLength > maxLength : false; + + const textExceedsRecommendedLength = + recommendedMaxLength !== undefined + ? fieldLength > recommendedMaxLength + : false; + + const shouldRenderError = + isError || textExceedsMaxLength || textExceedsRecommendedLength; + + 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 labelClassName = clsx( + styles['textarea-field__label'], + disabled && styles['textarea-field__label--disabled'], + ); + + const requiredTextClassName = clsx( + disabled && styles['textarea-field__required-text--disabled'], + ); + const fieldLengthCountClassName = clsx( + (textExceedsMaxLength || textExceedsRecommendedLength) && + styles['textarea-field--invalid-length'], + ); + + // Pick the smallest of the lengths to set as the maximum value allowed + const maxLengthShown = getMinValue(maxLength, recommendedMaxLength); + + return ( +
+ {shouldRenderOverline && ( +
+ {label && ( + + {label} + + )} + {required && showHint && ( + + (Required) + + )} + {!required && showHint && ( + + (Optional) + + )} +
+ )} + + {(fieldNote || maxLengthShown) && ( +
+ {fieldNote && ( + + {fieldNote} + + )} + {maxLengthShown && ( +
+ {fieldLength}{' '} + / {maxLengthShown} +
+ )} +
+ )} +
+ ); + }, +); + +TextareaField.displayName = 'TextareaField'; +TextArea.displayName = 'TextareaField.Textarea'; + +TextareaField.TextArea = TextArea; +TextareaField.Label = InputLabel; diff --git a/src/components/TextareaField/index.ts b/src/components/TextareaField/index.ts index 41336b605..600f526ef 100644 --- a/src/components/TextareaField/index.ts +++ b/src/components/TextareaField/index.ts @@ -1 +1,2 @@ export { TextareaField as default } from './TextareaField'; +export { TextareaField as TextareaFieldV2 } from './TextareaField-v2'; diff --git a/src/design-tokens/mixins.css b/src/design-tokens/mixins.css index 3645c350f..80dc29f99 100644 --- a/src/design-tokens/mixins.css +++ b/src/design-tokens/mixins.css @@ -243,6 +243,7 @@ &:read-only:not(:disabled) { border-color: transparent; + outline: none; padding-left: 0; }