Skip to content

Commit 5fee85c

Browse files
authored
Add TextArea component to RAC (#4779)
1 parent 9d8067c commit 5fee85c

File tree

5 files changed

+149
-68
lines changed

5 files changed

+149
-68
lines changed

packages/react-aria-components/docs/TextField.mdx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ import {TextField, Label, Input} from 'react-aria-components';
6666
flex-direction: column;
6767
width: fit-content;
6868

69-
.react-aria-Input {
69+
.react-aria-Input,
70+
.react-aria-TextArea {
7071
padding: 0.286rem;
7172
margin: 0;
7273
border: 1px solid var(--field-border);
@@ -231,6 +232,10 @@ An `Input` can be targeted with the `.react-aria-Input` CSS selector, or by over
231232

232233
<StateTable properties={docs.exports.InputRenderProps.properties} />
233234

235+
### TextArea
236+
237+
An `TextArea` can be targeted with the `.react-aria-TextArea` CSS selector, or by overriding with a custom `className`. It supports the same states as `Input` described above.
238+
234239
### Text
235240

236241
The help text elements within a `TextField` can be targeted with the `[slot=description]` and `[slot=errorMessage]` CSS selectors, or by adding a custom `className`.
@@ -351,6 +356,19 @@ TextField supports the `name` prop for integration with HTML forms. In addition,
351356
<MyTextField label="Email" name="email" type="email" />
352357
```
353358

359+
### Multi-line
360+
361+
TextField supports using the `TextArea` component in place of `Input` for multi-line text input.
362+
363+
```tsx example
364+
import {TextField, Label, TextArea} from 'react-aria-components';
365+
366+
<TextField>
367+
<Label>Comment</Label>
368+
<TextArea />
369+
</TextField>
370+
```
371+
354372
## Advanced customization
355373

356374
### Composition
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import {ContextValue, StyleRenderProps, useContextProps, useRenderProps} from './utils';
2+
import {InputRenderProps} from './Input';
3+
import {mergeProps, useFocusRing, useHover} from 'react-aria';
4+
import React, {createContext, ForwardedRef, forwardRef, TextareaHTMLAttributes} from 'react';
5+
6+
export interface TextAreaProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'className' | 'style'>, StyleRenderProps<InputRenderProps> {}
7+
8+
export const TextAreaContext = createContext<ContextValue<TextAreaProps, HTMLTextAreaElement>>({});
9+
10+
function TextArea(props: TextAreaProps, ref: ForwardedRef<HTMLTextAreaElement>) {
11+
[props, ref] = useContextProps(props, ref, TextAreaContext);
12+
13+
let {hoverProps, isHovered} = useHover({});
14+
let {isFocused, isFocusVisible, focusProps} = useFocusRing({
15+
isTextInput: true,
16+
autoFocus: props.autoFocus
17+
});
18+
19+
let renderProps = useRenderProps({
20+
...props,
21+
values: {isHovered, isFocused, isFocusVisible, isDisabled: props.disabled || false},
22+
defaultClassName: 'react-aria-TextArea'
23+
});
24+
25+
return (
26+
<textarea
27+
{...mergeProps(props, focusProps, hoverProps)}
28+
{...renderProps}
29+
ref={ref}
30+
data-hovered={isHovered || undefined}
31+
data-focus-visible={isFocusVisible || undefined} />
32+
);
33+
}
34+
/**
35+
* A textarea allows a user to input mult-line text.
36+
*/
37+
const _TextArea = forwardRef(TextArea);
38+
export {_TextArea as TextArea};

packages/react-aria-components/src/TextField.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import {ContextValue, DOMProps, forwardRefType, Provider, RenderProps, SlotProps
1515
import {filterDOMProps} from '@react-aria/utils';
1616
import {InputContext} from './Input';
1717
import {LabelContext} from './Label';
18-
import React, {createContext, ForwardedRef, forwardRef, useRef} from 'react';
18+
import React, {createContext, ForwardedRef, forwardRef, useCallback, useRef, useState} from 'react';
19+
import {TextAreaContext} from './TextArea';
1920
import {TextContext} from './Text';
2021
import {ValidationState} from '@react-types/shared';
2122

@@ -38,13 +39,24 @@ export const TextFieldContext = createContext<ContextValue<TextFieldProps, HTMLD
3839

3940
function TextField(props: TextFieldProps, ref: ForwardedRef<HTMLDivElement>) {
4041
[props, ref] = useContextProps(props, ref, TextFieldContext);
41-
let inputRef = useRef<HTMLInputElement>(null);
42+
let inputRef = useRef(null);
4243
let [labelRef, label] = useSlot();
43-
let {labelProps, inputProps, descriptionProps, errorMessageProps} = useTextField({
44+
let [inputElementType, setInputElementType] = useState('input');
45+
let {labelProps, inputProps, descriptionProps, errorMessageProps} = useTextField<any>({
4446
...props,
47+
inputElementType,
4548
label
4649
}, inputRef);
4750

51+
// Intercept setting the input ref so we can determine what kind of element we have.
52+
// useTextField uses this to determine what props to include.
53+
let inputOrTextAreaRef = useCallback((el) => {
54+
inputRef.current = el;
55+
if (el) {
56+
setInputElementType(el instanceof HTMLTextAreaElement ? 'textarea' : 'input');
57+
}
58+
}, []);
59+
4860
let renderProps = useRenderProps({
4961
...props,
5062
values: {
@@ -65,7 +77,8 @@ function TextField(props: TextFieldProps, ref: ForwardedRef<HTMLDivElement>) {
6577
<Provider
6678
values={[
6779
[LabelContext, {...labelProps, ref: labelRef}],
68-
[InputContext, {...inputProps, ref: inputRef}],
80+
[InputContext, {...inputProps, ref: inputOrTextAreaRef}],
81+
[TextAreaContext, {...inputProps, ref: inputOrTextAreaRef}],
6982
[TextContext, {
7083
slots: {
7184
description: descriptionProps,

packages/react-aria-components/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export {Table, Row, Cell, Column, TableHeader, TableBody, TableContext, useTable
4848
export {Tabs, TabList, TabPanel, Tab, TabsContext} from './Tabs';
4949
export {TagGroup, TagGroupContext, TagList, Tag} from './TagGroup';
5050
export {Text, TextContext} from './Text';
51+
export {TextArea, TextAreaContext} from './TextArea';
5152
export {TextField, TextFieldContext} from './TextField';
5253
export {ToggleButton, ToggleButtonContext} from './ToggleButton';
5354
export {TooltipTrigger, Tooltip} from './Tooltip';
@@ -89,6 +90,7 @@ export type {SwitchProps, SwitchRenderProps} from './Switch';
8990
export type {TableProps, TableRenderProps, TableHeaderProps, TableBodyProps, TableBodyRenderProps, ColumnProps, ColumnRenderProps, RowProps, RowRenderProps, CellProps, CellRenderProps} from './Table';
9091
export type {TabListProps, TabListRenderProps, TabPanelProps, TabPanelRenderProps, TabProps, TabsProps, TabRenderProps, TabsRenderProps} from './Tabs';
9192
export type {TagGroupProps, TagListProps, TagListRenderProps, TagProps, TagRenderProps} from './TagGroup';
93+
export type {TextAreaProps} from './TextArea';
9294
export type {TextFieldProps, TextFieldRenderProps} from './TextField';
9395
export type {TextProps} from './Text';
9496
export type {ToggleButtonProps, ToggleButtonRenderProps} from './ToggleButton';

packages/react-aria-components/test/TextField.test.js

Lines changed: 73 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -10,81 +10,91 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {Input, Label, Text, TextField, TextFieldContext} from '../';
13+
import {Input, Label, Text, TextArea, TextField, TextFieldContext} from '../';
1414
import React from 'react';
1515
import {render} from '@react-spectrum/test-utils';
1616
import userEvent from '@testing-library/user-event';
1717

1818
let TestTextField = (props) => (
1919
<TextField defaultValue="test" data-foo="bar" {...props}>
2020
<Label>Test</Label>
21-
<Input {...props.inputProps} />
21+
<props.input {...props.inputProps} />
2222
<Text slot="description">Description</Text>
2323
<Text slot="errorMessage">Error</Text>
2424
</TextField>
2525
);
2626

2727
describe('TextField', () => {
28-
it('provides slots', () => {
29-
let {getByRole} = render(<TestTextField />);
30-
31-
let input = getByRole('textbox');
32-
expect(input).toHaveValue('test');
33-
expect(input).toHaveAttribute('class', 'react-aria-Input');
34-
35-
expect(input.closest('.react-aria-TextField')).toHaveAttribute('data-foo', 'bar');
36-
37-
expect(input).toHaveAttribute('aria-labelledby');
38-
let label = document.getElementById(input.getAttribute('aria-labelledby'));
39-
expect(label).toHaveAttribute('class', 'react-aria-Label');
40-
expect(label).toHaveTextContent('Test');
41-
42-
expect(input).toHaveAttribute('aria-describedby');
43-
expect(input.getAttribute('aria-describedby').split(' ').map(id => document.getElementById(id).textContent).join(' ')).toBe('Description Error');
44-
});
45-
46-
it('should support slot', () => {
47-
let {getByRole} = render(
48-
<TextFieldContext.Provider value={{slots: {test: {'aria-label': 'test'}}}}>
49-
<TestTextField slot="test" />
50-
</TextFieldContext.Provider>
51-
);
52-
53-
let textbox = getByRole('textbox');
54-
expect(textbox.closest('.react-aria-TextField')).toHaveAttribute('slot', 'test');
55-
expect(textbox).toHaveAttribute('aria-label', 'test');
56-
});
57-
58-
it('should support hover state', () => {
59-
let {getByRole} = render(<TestTextField inputProps={{className: ({isHovered}) => isHovered ? 'hover' : ''}} />);
60-
let input = getByRole('textbox');
61-
62-
expect(input).not.toHaveAttribute('data-hovered');
63-
expect(input).not.toHaveClass('hover');
64-
65-
userEvent.hover(input);
66-
expect(input).toHaveAttribute('data-hovered', 'true');
67-
expect(input).toHaveClass('hover');
68-
69-
userEvent.unhover(input);
70-
expect(input).not.toHaveAttribute('data-hovered');
71-
expect(input).not.toHaveClass('hover');
72-
});
73-
74-
it('should support focus visible state', () => {
75-
let {getByRole} = render(<TestTextField inputProps={{className: ({isFocusVisible}) => isFocusVisible ? 'focus' : ''}} />);
76-
let input = getByRole('textbox');
77-
78-
expect(input).not.toHaveAttribute('data-focus-visible');
79-
expect(input).not.toHaveClass('focus');
80-
81-
userEvent.tab();
82-
expect(document.activeElement).toBe(input);
83-
expect(input).toHaveAttribute('data-focus-visible', 'true');
84-
expect(input).toHaveClass('focus');
85-
86-
userEvent.tab();
87-
expect(input).not.toHaveAttribute('data-focus-visible');
88-
expect(input).not.toHaveClass('focus');
28+
describe.each([
29+
{name: 'Input', component: Input},
30+
{name: 'TextArea', component: TextArea}]
31+
)('$name', ({name, component}) => {
32+
it('provides slots', () => {
33+
let {getByRole} = render(<TestTextField input={component} />);
34+
35+
let input = getByRole('textbox');
36+
expect(input).toHaveValue('test');
37+
expect(input).toHaveAttribute('class', `react-aria-${name}`);
38+
if (name === 'Input') {
39+
expect(input).toHaveAttribute('type', 'text');
40+
} else {
41+
expect(input).not.toHaveAttribute('type');
42+
}
43+
44+
expect(input.closest('.react-aria-TextField')).toHaveAttribute('data-foo', 'bar');
45+
46+
expect(input).toHaveAttribute('aria-labelledby');
47+
let label = document.getElementById(input.getAttribute('aria-labelledby'));
48+
expect(label).toHaveAttribute('class', 'react-aria-Label');
49+
expect(label).toHaveTextContent('Test');
50+
51+
expect(input).toHaveAttribute('aria-describedby');
52+
expect(input.getAttribute('aria-describedby').split(' ').map(id => document.getElementById(id).textContent).join(' ')).toBe('Description Error');
53+
});
54+
55+
it('should support slot', () => {
56+
let {getByRole} = render(
57+
<TextFieldContext.Provider value={{slots: {test: {'aria-label': 'test'}}}}>
58+
<TestTextField slot="test" input={component} />
59+
</TextFieldContext.Provider>
60+
);
61+
62+
let textbox = getByRole('textbox');
63+
expect(textbox.closest('.react-aria-TextField')).toHaveAttribute('slot', 'test');
64+
expect(textbox).toHaveAttribute('aria-label', 'test');
65+
});
66+
67+
it('should support hover state', () => {
68+
let {getByRole} = render(<TestTextField input={component} inputProps={{className: ({isHovered}) => isHovered ? 'hover' : ''}} />);
69+
let input = getByRole('textbox');
70+
71+
expect(input).not.toHaveAttribute('data-hovered');
72+
expect(input).not.toHaveClass('hover');
73+
74+
userEvent.hover(input);
75+
expect(input).toHaveAttribute('data-hovered', 'true');
76+
expect(input).toHaveClass('hover');
77+
78+
userEvent.unhover(input);
79+
expect(input).not.toHaveAttribute('data-hovered');
80+
expect(input).not.toHaveClass('hover');
81+
});
82+
83+
it('should support focus visible state', () => {
84+
let {getByRole} = render(<TestTextField input={component} inputProps={{className: ({isFocusVisible}) => isFocusVisible ? 'focus' : ''}} />);
85+
let input = getByRole('textbox');
86+
87+
expect(input).not.toHaveAttribute('data-focus-visible');
88+
expect(input).not.toHaveClass('focus');
89+
90+
userEvent.tab();
91+
expect(document.activeElement).toBe(input);
92+
expect(input).toHaveAttribute('data-focus-visible', 'true');
93+
expect(input).toHaveClass('focus');
94+
95+
userEvent.tab();
96+
expect(input).not.toHaveAttribute('data-focus-visible');
97+
expect(input).not.toHaveClass('focus');
98+
});
8999
});
90100
});

0 commit comments

Comments
 (0)