Skip to content

Commit 3ba551d

Browse files
authored
Add new textarea component using textfield (#64)
* Add new textarea component using textfield * Add new textarea component using textfield * Fix merge
1 parent 86245ee commit 3ba551d

File tree

6 files changed

+229
-9
lines changed

6 files changed

+229
-9
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"version": "0.5.2",
2+
"version": "0.5.3",
33
"license": "MIT",
44
"main": "dist/index.js",
55
"typings": "dist/index.d.ts",

src/textfield/TextArea.tsx

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import React, { RefObject, useCallback, useRef } from 'react';
2+
import {
3+
TextFieldBase,
4+
AriaTextFieldProps,
5+
TextFieldRef,
6+
} from './TextFieldBase';
7+
import { useTextField } from '@react-aria/textfield';
8+
import { chain, useLayoutEffect } from '@react-aria/utils';
9+
import { useControlledState } from '@react-stately/utils';
10+
import { useProviderProps } from '../provider';
11+
import { AddonableProps } from '../types';
12+
13+
export interface TextAreaProps extends AriaTextFieldProps, AddonableProps {
14+
className?: string;
15+
variant?: 'default' | 'quiet';
16+
/** The height of the text area */
17+
height?: number;
18+
}
19+
function TextArea(props: TextAreaProps, ref: RefObject<TextFieldRef>) {
20+
props = useProviderProps(props);
21+
const {
22+
isDisabled = false,
23+
isReadOnly = false,
24+
isRequired = false,
25+
onChange,
26+
height,
27+
...otherProps
28+
} = props;
29+
30+
// not in stately because this is so we know when to re-measure, which is a spectrum design
31+
const [inputValue, setInputValue] = useControlledState(
32+
props.value,
33+
props.defaultValue,
34+
() => {}
35+
);
36+
let inputRef = useRef<HTMLTextAreaElement>(null);
37+
38+
const onHeightChange = useCallback(() => {
39+
// Quiet textareas always grow based on their text content.
40+
// Standard textareas also grow by default, unless an explicit height is set.
41+
const input = inputRef.current;
42+
43+
if (input && !height) {
44+
const prevAlignment = input.style.alignSelf;
45+
const prevOverflow = input.style.overflow;
46+
// Firefox scroll position is lost when overflow: 'hidden' is applied so we skip applying it.
47+
// The measure/applied height is also incorrect/reset if we turn on and off
48+
// overflow: hidden in Firefox https://bugzilla.mozilla.org/show_bug.cgi?id=1787062
49+
const isFirefox = 'MozAppearance' in input.style;
50+
if (!isFirefox) {
51+
input.style.overflow = 'hidden';
52+
}
53+
input.style.alignSelf = 'start';
54+
input.style.height = 'auto';
55+
// offsetHeight - clientHeight accounts for the border/padding.
56+
input.style.height = `${input.scrollHeight +
57+
(input.offsetHeight - input.clientHeight)}px`;
58+
input.style.overflow = prevOverflow;
59+
input.style.alignSelf = prevAlignment;
60+
}
61+
}, [inputRef, height]);
62+
63+
useLayoutEffect(() => {
64+
if (inputRef.current) {
65+
onHeightChange();
66+
}
67+
}, [onHeightChange, inputValue, inputRef]);
68+
69+
let {
70+
labelProps,
71+
inputProps,
72+
descriptionProps,
73+
errorMessageProps,
74+
} = useTextField(
75+
{
76+
...props,
77+
onChange: chain(onChange, setInputValue),
78+
inputElementType: 'textarea',
79+
},
80+
inputRef
81+
);
82+
83+
return (
84+
<TextFieldBase
85+
{...otherProps}
86+
ref={ref}
87+
labelProps={labelProps}
88+
inputProps={inputProps}
89+
descriptionProps={descriptionProps}
90+
errorMessageProps={errorMessageProps}
91+
multiLine
92+
isDisabled={isDisabled}
93+
isReadOnly={isReadOnly}
94+
isRequired={isRequired}
95+
height={height}
96+
inputRef={inputRef}
97+
/>
98+
);
99+
}
100+
101+
/**
102+
* TextAreas are multiline text inputs, useful for cases where users have
103+
* a sizable amount of text to enter. They allow for all customizations that
104+
* are available to text fields.
105+
*/
106+
// @ts-ignore
107+
let _TextArea = React.forwardRef(TextArea);
108+
export { _TextArea as TextArea };

src/textfield/TextField.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,12 @@ function TextField(props: TextFieldProps, ref: RefObject<TextFieldRef>) {
1717
// Call use provider props so the textfield can inherit from the provider
1818
// E.x. disabled, readOnly, etc.
1919
props = useProviderProps(props);
20-
let inputRef = useRef<HTMLInputElement>();
20+
let inputRef = useRef<HTMLInputElement>(null);
2121
let {
2222
labelProps,
2323
inputProps,
2424
descriptionProps,
2525
errorMessageProps,
26-
// @ts-ignore
2726
} = useTextField(props, inputRef);
2827
return (
2928
<TextFieldBase
@@ -33,7 +32,6 @@ function TextField(props: TextFieldProps, ref: RefObject<TextFieldRef>) {
3332
descriptionProps={descriptionProps}
3433
errorMessageProps={errorMessageProps}
3534
ref={ref}
36-
// @ts-ignore
3735
inputRef={inputRef}
3836
/>
3937
);

src/textfield/TextFieldBase.tsx

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ interface TextFieldBaseProps
101101
isLoading?: boolean;
102102
className?: string;
103103
variant?: 'default' | 'quiet';
104+
height?: number;
104105
}
105106

106107
export interface TextFieldRef
@@ -132,6 +133,7 @@ function TextFieldBase(props: TextFieldBaseProps, ref: Ref<TextFieldRef>) {
132133
addonBefore,
133134
className,
134135
variant = 'default',
136+
height,
135137
} = props;
136138
let { hoverProps, isHovered } = useHover({ isDisabled });
137139
let [isFocused, setIsFocused] = React.useState(false);
@@ -179,6 +181,7 @@ function TextFieldBase(props: TextFieldBaseProps, ref: Ref<TextFieldRef>) {
179181
css={css`
180182
display: flex;
181183
flex-direction: row;
184+
position: relative;
182185
align-items: center;
183186
min-width: 270px;
184187
background-color: ${theme.components.textField.backgroundColor};
@@ -213,23 +216,41 @@ function TextFieldBase(props: TextFieldBaseProps, ref: Ref<TextFieldRef>) {
213216
box-sizing: border-box;
214217
background-color: transparent;
215218
color: ${theme.textColors.white90};
216-
height: ${theme.singleLineHeight}px;
219+
height: ${height ?? theme.singleLineHeight}px;
217220
padding: ${theme.spacing.padding4}px ${theme.spacing.padding8}px;
221+
margin-right: 36px;
218222
transition: all 0.2s ease-in-out;
219223
/** provide an alternate highlight */
220224
outline: none;
221225
border: none;
222226
}
223227
228+
&.ac-textfield--multiline {
229+
height: ${height ?? theme.singleLineHeight}px;
230+
${height && `padding-top: ${theme.spacing.padding4}px;`}
231+
232+
textarea {
233+
resize: none;
234+
overflow-y: scroll;
235+
}
236+
}
237+
224238
&.ac-textfield--invalid {
225239
color: ${theme.colors.statusDanger};
226240
border: 1px solid ${theme.colors.statusDanger};
241+
.ac-textfield__input {
242+
// Make room for the invalid icon (outer padding + icon width + inner padding)
243+
padding-right: ${theme.spacing.padding8 +
244+
24 +
245+
theme.spacing.padding4}px;
246+
}
227247
}
228248
.ac-textfield__validation-icon {
229249
/* Animate in the icon */
230250
animation: ${appearKeyframes} ${0.2}s forwards ease-in-out;
231-
flex: none;
232-
margin-right: ${theme.spacing.padding8}px;
251+
top: ${theme.spacing.padding8}px;
252+
right: ${theme.spacing.padding8}px;
253+
position: absolute;
233254
&.ac-textfield__validation-icon--invalid {
234255
color: ${theme.colors.statusDanger};
235256
}
@@ -272,8 +293,7 @@ function TextFieldBase(props: TextFieldBaseProps, ref: Ref<TextFieldRef>) {
272293
textField = React.cloneElement(
273294
textField,
274295
mergeProps(textField.props, {
275-
// TODO support muli-line text areas
276-
className: multiLine ? 'ac-text-field--multiline' : '',
296+
className: multiLine ? 'ac-textfield--multiline' : '',
277297
})
278298
);
279299
}

src/textfield/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './TextField';
2+
export * from './TextArea';
23
export * from './TextFieldBase';

stories/TextArea.stories.tsx

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import React from 'react';
2+
import { Meta, Story } from '@storybook/react';
3+
import { withDesign } from 'storybook-addon-designs';
4+
import { Form, TextArea, TextAreaProps } from '../src';
5+
import InfoTip from './components/InfoTip';
6+
7+
const meta: Meta = {
8+
title: 'TextArea',
9+
component: TextArea,
10+
decorators: [withDesign],
11+
parameters: {
12+
controls: { expanded: true },
13+
design: {
14+
type: 'figma',
15+
url:
16+
'https://www.figma.com/file/5mMInYH9JdJY389s8iBVQm/Component-Library?node-id=76%3A505',
17+
},
18+
},
19+
};
20+
21+
export default meta;
22+
23+
/**
24+
* A gallery of all the variants
25+
*/
26+
export const Gallery = () => (
27+
<Form>
28+
<TextArea
29+
label="Default height textarea"
30+
placeholder="Enter your description"
31+
defaultValue="Description"
32+
isRequired
33+
/>
34+
<TextArea
35+
label="Tall description so you have to scroll"
36+
placeholder="Enter your description"
37+
defaultValue="Blah blah blah. Blah blah blaBlah blah blaBlah blah blaBlah blah blaBlah blah blaBlah blah blaBlah blah blaBlah blah blaBlah blah blaBlah blah blaBlah blah bla Blah blah blaBlah blah blaBlah blah bla"
38+
height={100}
39+
/>
40+
<TextArea
41+
label="Name"
42+
placeholder="Enter your description"
43+
isRequired
44+
validationState={'invalid'}
45+
/>
46+
<TextArea
47+
label="Name"
48+
placeholder="Enter your description"
49+
isRequired
50+
validationState={'invalid'}
51+
errorMessage="This field is required"
52+
height={100}
53+
/>
54+
<TextArea
55+
label="Charge"
56+
labelExtra={
57+
<InfoTip postfix={false}>The amount you will be charged</InfoTip>
58+
}
59+
placeholder="Enter your amount"
60+
isRequired
61+
validationState={'invalid'}
62+
errorMessage="This field is required"
63+
/>
64+
<TextArea
65+
label="Disabled"
66+
labelExtra={
67+
<InfoTip postfix={false}>The amount you will be charged</InfoTip>
68+
}
69+
placeholder="Enter your amount"
70+
isDisabled
71+
value="100"
72+
/>
73+
<TextArea
74+
label="Read Only"
75+
labelExtra={
76+
<InfoTip postfix={false}>The amount you will be charged</InfoTip>
77+
}
78+
placeholder="Enter your amount"
79+
isReadOnly
80+
value="100"
81+
/>
82+
</Form>
83+
);
84+
85+
const Template: Story<TextAreaProps> = args => (
86+
<Form>
87+
<TextArea {...args}>Label Text</TextArea>
88+
</Form>
89+
);
90+
91+
// By passing using the Args format for exported stories, you can control the props for a component for reuse in a test
92+
// https://storybook.js.org/docs/react/workflows/unit-testing
93+
export const Default = Template.bind({});

0 commit comments

Comments
 (0)