diff --git a/src/components/TextArea/TextArea.module.css b/src/components/TextArea/TextArea.module.css new file mode 100644 index 0000000000..25526b2f52 --- /dev/null +++ b/src/components/TextArea/TextArea.module.css @@ -0,0 +1,12 @@ +@import '../../design-tokens/mixins.css'; + +/*------------------------------------*\ +    # TEXTAREA +\*------------------------------------*/ + +/** + * Default input styles + */ +.textarea { + @mixin inputStyles; +} diff --git a/src/components/TextArea/TextArea.tsx b/src/components/TextArea/TextArea.tsx new file mode 100644 index 0000000000..c9ed6ddd79 --- /dev/null +++ b/src/components/TextArea/TextArea.tsx @@ -0,0 +1,53 @@ +import clsx from 'clsx'; +import React, { forwardRef } from 'react'; +import styles from './TextArea.module.css'; + +export interface Props + extends 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; +} + +/** + * BETA: This component is still a work in progress and is subject to change. + * + * `import {TextArea} from "@chanzuckerberg/eds";` + * + * TODO: update this comment with a description of the component. + * TODO: add forwardref + */ +export const TextArea = forwardRef( + ( + { className, children, defaultValue = '', isError = false, ...other }, + ref, + ) => { + const componentClassName = clsx( + styles['textarea'], + isError && styles['error'], + className, + ); + + return ( + + ); + }, +); diff --git a/src/components/TextArea/index.ts b/src/components/TextArea/index.ts new file mode 100644 index 0000000000..5b73816df3 --- /dev/null +++ b/src/components/TextArea/index.ts @@ -0,0 +1 @@ +export { TextArea as default } from './TextArea'; diff --git a/src/components/TextareaField/TextareaField.module.css b/src/components/TextareaField/TextareaField.module.css new file mode 100644 index 0000000000..158869f48e --- /dev/null +++ b/src/components/TextareaField/TextareaField.module.css @@ -0,0 +1,19 @@ +/*------------------------------------*\ + # TEXTAREA FIELD +\*------------------------------------*/ + +/** + * Wraps the Label and the optional/required indicator. + */ +.textarea-field__overline { + display: flex; + justify-content: space-between; + margin-bottom: var(--eds-size-half); + color: var(--eds-theme-color-form-label); +} +.textarea-field__overline--no-label { + justify-content: flex-end; +} +.textarea-field__overline--disabled { + color: var(--eds-theme-color-text-disabled); +} diff --git a/src/components/TextareaField/TextareaField.stories.tsx b/src/components/TextareaField/TextareaField.stories.tsx new file mode 100644 index 0000000000..3606e0cd5f --- /dev/null +++ b/src/components/TextareaField/TextareaField.stories.tsx @@ -0,0 +1,58 @@ +import { BADGE } from '@geometricpanda/storybook-addon-badges'; +import type { StoryObj, Meta } from '@storybook/react'; +import React from 'react'; + +import { TextareaField } from './TextareaField'; + +export default { + title: 'Components/TextareaField', + component: TextareaField, + args: { + 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', + width: '100', + height: '100', + fieldNote: 'Test', + }, + parameters: { + layout: 'centered', + badges: [BADGE.BETA], + }, +} as Meta; + +type Args = React.ComponentProps; + +export const Default: StoryObj = { + render: (args) => ( + + ), +}; + +export const UsingChildren: StoryObj = { + 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: '', + }, +}; + +export const WhenDisabled: StoryObj = { + args: { + disabled: true, + }, + parameters: { + axe: { + // Disabled input does not need to meet color contrast + disabledRules: ['color-contrast'], + }, + }, +}; + +export const WhenInvalid: StoryObj = { + args: { + maxLength: 10, + }, +}; diff --git a/src/components/TextareaField/TextareaField.test.ts b/src/components/TextareaField/TextareaField.test.ts new file mode 100644 index 0000000000..dd51f7e724 --- /dev/null +++ b/src/components/TextareaField/TextareaField.test.ts @@ -0,0 +1,6 @@ +import { generateSnapshots } from '@chanzuckerberg/story-utils'; +import * as stories from './TextareaField.stories'; + +describe('', () => { + generateSnapshots(stories); +}); diff --git a/src/components/TextareaField/TextareaField.tsx b/src/components/TextareaField/TextareaField.tsx new file mode 100644 index 0000000000..53a4662e63 --- /dev/null +++ b/src/components/TextareaField/TextareaField.tsx @@ -0,0 +1,129 @@ +import clsx from 'clsx'; +import type { ReactNode } from 'react'; +import React, { forwardRef, useId } from 'react'; +import styles from './TextareaField.module.css'; +import FieldNote from '../FieldNote'; +import Label from '../Label'; +import Text from '../Text'; +import TextArea from '../TextArea'; + +export type Props = React.TextareaHTMLAttributes & { + /** + * Aria-label to provide an accesible name for the text input if no visible label is provided. + */ + 'aria-label'?: string; + /** + * 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 + */ + id?: string; + /** + * Error state of the form field + */ + isError?: boolean; + /** + * HTML label text + */ + label?: string; +}; + +/** + * BETA: This component is still a work in progress and is subject to change. + * + * `import {TextareaField} from "@chanzuckerberg/eds";` + * + * TODO: update this comment with a description of the component. + */ +export const TextareaField = forwardRef( + ( + { + 'aria-describedby': ariaDescribedBy, + children, + className, + disabled, + fieldNote, + id, + isError, + label, + required, + ...other + }, + ref, + ) => { + if ( + process.env.NODE_ENV !== 'production' && + !label && + !other['aria-label'] + ) { + throw new Error('You must provide a visible label or aria-label'); + } + + 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 generatedIdVar = useId(); + const idVar = id || generatedIdVar; + + const generatedAriaDescribedById = useId(); + const ariaDescribedByVar = fieldNote + ? ariaDescribedBy || generatedAriaDescribedById + : undefined; + + const componentClassName = clsx(styles['textarea-field'], className); + + return ( +
+ {shouldRenderOverline && ( +
+ {label &&
+ )} + + {fieldNote && ( + + {fieldNote} + + )} +
+ ); + }, +); diff --git a/src/components/TextareaField/__snapshots__/TextareaField.test.ts.snap b/src/components/TextareaField/__snapshots__/TextareaField.test.ts.snap new file mode 100644 index 0000000000..663caea6f5 --- /dev/null +++ b/src/components/TextareaField/__snapshots__/TextareaField.test.ts.snap @@ -0,0 +1,146 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` Default story renders snapshot 1`] = ` +
+
+ +
+ +
+ Test +
+
+`; + +exports[` UsingChildren story renders snapshot 1`] = ` +
+
+ +
+ +
+ Test +
+
+`; + +exports[` WhenDisabled story renders snapshot 1`] = ` +
+
+ +
+ +
+ Test +
+
+`; + +exports[` WhenInvalid story renders snapshot 1`] = ` +
+
+ +
+ +
+ Test +
+
+`; diff --git a/src/components/TextareaField/index.ts b/src/components/TextareaField/index.ts new file mode 100644 index 0000000000..41336b605e --- /dev/null +++ b/src/components/TextareaField/index.ts @@ -0,0 +1 @@ +export { TextareaField as default } from './TextareaField'; diff --git a/src/design-tokens/mixins.css b/src/design-tokens/mixins.css index 8599e6df91..18b5e640b3 100644 --- a/src/design-tokens/mixins.css +++ b/src/design-tokens/mixins.css @@ -132,7 +132,8 @@ /** * Input error state */ - &.error { + &.error, + &:invalid { border-color: var(--eds-theme-color-border-utility-error-strong); } diff --git a/src/index.ts b/src/index.ts index 1e6d631f73..c59aa09532 100644 --- a/src/index.ts +++ b/src/index.ts @@ -85,6 +85,8 @@ export { default as TableRow } from './components/TableRow'; export { default as Tabs } from './components/Tabs'; export { default as Tag } from './components/Tag'; export { default as Text } from './components/Text'; +export { default as TextArea } from './components/TextArea'; +export { default as TextareaField } from './components/TextareaField'; export { default as TimelineNav } from './components/TimelineNav'; export { default as TimelineNavPanel } from './components/TimelineNavPanel'; export { default as Toast } from './components/Toast';