Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[core] fix(TextArea): resize vertically in controlled mode #5975

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 5 additions & 11 deletions packages/core/src/components/forms/text-inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,17 +69,11 @@ Apply `Classes.INPUT` on an `input[type="text"]`. You should also specify `dir="

@## Text area

Apply `Classes.INPUT` on a `<textarea>`, or use the `TextArea` React component.

```tsx
<TextArea
growVertically={true}
large={true}
intent={Intent.PRIMARY}
onChange={this.handleChange}
value={this.state.value}
/>
```
Use the `<TextArea>` React component, which can be controlled similar to an `<InputGroup>` or `<input>` element.

@reactExample TextAreaExample

Alternatively, you may apply `Classes.INPUT` to a `<textarea>` element.

@css textarea

Expand Down
38 changes: 19 additions & 19 deletions packages/core/src/components/forms/textArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export interface ITextAreaProps extends IntentProps, Props, React.TextareaHTMLAt
inputRef?: React.Ref<HTMLTextAreaElement>;
}

export interface ITextAreaState {
export interface TextAreaState {
height?: number;
}

Expand All @@ -61,10 +61,10 @@ export interface ITextAreaState {
*
* @see https://blueprintjs.com/docs/#core/components/text-inputs.text-area
*/
export class TextArea extends AbstractPureComponent2<TextAreaProps, ITextAreaState> {
export class TextArea extends AbstractPureComponent2<TextAreaProps, TextAreaState> {
public static displayName = `${DISPLAYNAME_PREFIX}.TextArea`;

public state: ITextAreaState = {};
public state: TextAreaState = {};

// used to measure and set the height of the component on first mount
public textareaElement: HTMLTextAreaElement | null = null;
Expand All @@ -75,14 +75,17 @@ export class TextArea extends AbstractPureComponent2<TextAreaProps, ITextAreaSta
this.props.inputRef,
);

public componentDidMount() {
if (this.props.growVertically && this.textareaElement !== null) {
// HACKHACK: this should probably be done in getSnapshotBeforeUpdate
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this doesn't really help us. removed this comment

/* eslint-disable-next-line react/no-did-mount-set-state */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a significantly better way to do this, and I think the potential for layout thrashing is minimal. removed this comment.

this.setState({
height: this.textareaElement?.scrollHeight,
});
private maybeSyncHeightToScrollHeight = () => {
if (this.props.growVertically && this.textareaElement != null) {
const { scrollHeight } = this.textareaElement;
if (scrollHeight > 0) {
this.setState({ height: scrollHeight });
}
}
};

public componentDidMount() {
this.maybeSyncHeightToScrollHeight();
}

public componentDidUpdate(prevProps: TextAreaProps) {
Expand All @@ -91,6 +94,10 @@ export class TextArea extends AbstractPureComponent2<TextAreaProps, ITextAreaSta
this.handleRef = refHandler(this, "textareaElement", this.props.inputRef);
setRef(this.props.inputRef, this.textareaElement);
}

if (prevProps.value !== this.props.value || prevProps.style !== this.props.style) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note that props.style changes can cause layout changes which affect scrollHeight

this.maybeSyncHeightToScrollHeight();
}
}

public render() {
Expand Down Expand Up @@ -130,14 +137,7 @@ export class TextArea extends AbstractPureComponent2<TextAreaProps, ITextAreaSta
}

private handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (this.props.growVertically) {
this.setState({
height: e.target.scrollHeight,
});
}

if (this.props.onChange != null) {
this.props.onChange(e);
}
this.maybeSyncHeightToScrollHeight();
this.props.onChange?.(e);
};
}
57 changes: 44 additions & 13 deletions packages/core/test/forms/textAreaTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,36 @@
import { assert } from "chai";
import { mount } from "enzyme";
import * as React from "react";
import * as ReactDOM from "react-dom";

import { TextArea } from "../../src";

describe("<TextArea>", () => {
it("can resize automatically", () => {
const wrapper = mount(<TextArea growVertically={true} />);
let containerElement: HTMLElement | undefined;

beforeEach(() => {
containerElement = document.createElement("div");
containerElement.setAttribute("style", "width: 1000px; height: 1000px;");
document.body.appendChild(containerElement);
});

afterEach(() => {
ReactDOM.unmountComponentAtNode(containerElement!);
containerElement!.remove();
});

// HACKHACK: skipped test, see https://github.com/palantir/blueprint/issues/5976
it.skip("can resize automatically", () => {
const wrapper = mount(<TextArea growVertically={true} />, { attachTo: containerElement });
const textarea = wrapper.find("textarea");

textarea.simulate("change", { target: { scrollHeight: 500 } });

assert.equal((textarea.getDOMNode() as HTMLElement).style.height, "500px");
assert.equal(textarea.getDOMNode<HTMLElement>().style.height, "500px");
});

it("doesn't resize by default", () => {
const wrapper = mount(<TextArea />);
const wrapper = mount(<TextArea />, { attachTo: containerElement });
const textarea = wrapper.find("textarea");

textarea.simulate("change", {
Expand All @@ -40,19 +55,22 @@ describe("<TextArea>", () => {
},
});

assert.equal((textarea.getDOMNode() as HTMLElement).style.height, "");
assert.equal(textarea.getDOMNode<HTMLElement>().style.height, "");
});

it("doesn't clobber user-supplied styles", () => {
const wrapper = mount(<TextArea growVertically={true} style={{ marginTop: 10 }} />);
const wrapper = mount(<TextArea growVertically={true} style={{ marginTop: 10 }} />, {
attachTo: containerElement,
});
const textarea = wrapper.find("textarea");

textarea.simulate("change", { target: { scrollHeight: 500 } });

assert.equal((textarea.getDOMNode() as HTMLElement).style.marginTop, "10px");
assert.equal(textarea.getDOMNode<HTMLElement>().style.marginTop, "10px");
});

it("can fit large initial content", () => {
// HACKHACK: skipped test, see https://github.com/palantir/blueprint/issues/5976
it.skip("can fit large initial content", () => {
const initialValue = `Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Aenean finibus eget enim non accumsan.
Nunc lobortis luctus magna eleifend consectetur.
Expand All @@ -61,10 +79,12 @@ describe("<TextArea>", () => {
Sed eros sapien, semper sed imperdiet sed,
dictum eget purus. Donec porta accumsan pretium.
Fusce at felis mattis, tincidunt erat non, varius erat.`;
const wrapper = mount(<TextArea growVertically={true} value={initialValue} style={{ marginTop: 10 }} />);
const wrapper = mount(<TextArea growVertically={true} value={initialValue} style={{ marginTop: 10 }} />, {
attachTo: containerElement,
});
const textarea = wrapper.find("textarea");
const scrollHeightInPixels = `${(textarea.getDOMNode() as HTMLElement).scrollHeight}px`;
assert.equal((textarea.getDOMNode() as HTMLElement).style.height, scrollHeightInPixels);
const scrollHeightInPixels = `${textarea.getDOMNode<HTMLElement>().scrollHeight}px`;
assert.equal(textarea.getDOMNode<HTMLElement>().style.height, scrollHeightInPixels);
});

it("updates on ref change", () => {
Expand All @@ -81,7 +101,7 @@ describe("<TextArea>", () => {
textAreaNew = ref;
};

const textAreawrapper = mount(<TextArea id="textarea" inputRef={textAreaRefCallback} />);
const textAreawrapper = mount(<TextArea inputRef={textAreaRefCallback} />, { attachTo: containerElement });
assert.instanceOf(textArea, HTMLTextAreaElement);
assert.strictEqual(callCount, 1);

Expand All @@ -97,11 +117,22 @@ describe("<TextArea>", () => {
const textAreaRef = React.createRef<HTMLTextAreaElement>();
const textAreaNewRef = React.createRef<HTMLTextAreaElement>();

const textAreawrapper = mount(<TextArea id="textarea" inputRef={textAreaRef} />);
const textAreawrapper = mount(<TextArea inputRef={textAreaRef} />, { attachTo: containerElement });
assert.instanceOf(textAreaRef.current, HTMLTextAreaElement);

textAreawrapper.setProps({ inputRef: textAreaNewRef });
assert.isNull(textAreaRef.current);
assert.instanceOf(textAreaNewRef.current, HTMLTextAreaElement);
});

// HACKHACK: skipped test, see https://github.com/palantir/blueprint/issues/5976
it.skip("resizes when props change if growVertically is true", () => {
const initialText = "A";
const longText = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
const wrapper = mount(<TextArea growVertically={true} value={initialText} />, { attachTo: containerElement });
const initialHeight = wrapper.find("textarea").getDOMNode<HTMLElement>().style.height;
wrapper.setProps({ value: longText }).update();
const newHeight = wrapper.find("textarea").getDOMNode<HTMLElement>().style.height;
assert.notEqual(newHeight, initialHeight);
});
});
1 change: 1 addition & 0 deletions packages/docs-app/src/examples/core-examples/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export * from "./radioExample";
export * from "./sliderExample";
export * from "./switchExample";
export * from "./tagInputExample";
export * from "./textAreaExample";
export * from "./textExample";
export * from "./spinnerExample";
export * from "./tabsExample";
Expand Down
106 changes: 106 additions & 0 deletions packages/docs-app/src/examples/core-examples/textAreaExample.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Copyright 2023 Palantir Technologies, Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import * as React from "react";

import { AnchorButton, ControlGroup, H5, Switch, TextArea } from "@blueprintjs/core";
import { Example, ExampleProps, handleBooleanChange } from "@blueprintjs/docs-theme";
import { Tooltip2 } from "@blueprintjs/popover2";

const INTITIAL_CONTROLLED_TEXT = "In a galaxy far, far away...";
const CONTROLLED_TEXT_TO_APPEND =
"The approach will not be easy. You are required to maneuver straight down this trench and skim the surface to this point. The target area is only two meters wide. It's a small thermal exhaust port, right below the main port. The shaft leads directly to the reactor system.";

interface TextAreaExampleState {
controlled: boolean;
disabled: boolean;
growVertically: boolean;
large: boolean;
readOnly: boolean;
small: boolean;
value: string;
}

export class TextAreaExample extends React.PureComponent<ExampleProps, TextAreaExampleState> {
public state: TextAreaExampleState = {
controlled: false,
disabled: false,
growVertically: true,
large: false,
readOnly: false,
small: false,
value: INTITIAL_CONTROLLED_TEXT,
};

private handleControlledChange = handleBooleanChange(controlled => this.setState({ controlled }));

private handleDisabledChange = handleBooleanChange(disabled => this.setState({ disabled }));

private handleGrowVerticallyChange = handleBooleanChange(growVertically => this.setState({ growVertically }));

private handleLargeChange = handleBooleanChange(large => this.setState({ large, ...(large && { small: false }) }));

private handleReadOnlyChange = handleBooleanChange(readOnly => this.setState({ readOnly }));

private handleSmallChange = handleBooleanChange(small => this.setState({ small, ...(small && { large: false }) }));

private appendControlledText = () =>
this.setState(({ value }) => ({ value: value + " " + CONTROLLED_TEXT_TO_APPEND }));

private resetControlledText = () => this.setState({ value: INTITIAL_CONTROLLED_TEXT });

public render() {
const { controlled, value, ...textAreaProps } = this.state;

return (
<Example options={this.renderOptions()} {...this.props}>
<TextArea style={{ display: controlled ? undefined : "none" }} value={value} {...textAreaProps} />
<TextArea
style={{ display: controlled ? "none" : undefined }}
placeholder="Type something..."
{...textAreaProps}
/>
</Example>
);
}

private renderOptions() {
const { controlled, disabled, growVertically, large, readOnly, small } = this.state;
return (
<>
<H5>Appearance props</H5>
<Switch label="Large" disabled={small} onChange={this.handleLargeChange} checked={large} />
<Switch label="Small" disabled={large} onChange={this.handleSmallChange} checked={small} />
<H5>Behavior props</H5>
<Switch label="Disabled" onChange={this.handleDisabledChange} checked={disabled} />
<Switch label="Read-only" onChange={this.handleReadOnlyChange} checked={readOnly} />
<Switch label="Grow vertically" onChange={this.handleGrowVerticallyChange} checked={growVertically} />
<Switch label="Controlled usage" onChange={this.handleControlledChange} checked={controlled} />
<ControlGroup>
<AnchorButton
disabled={!controlled}
text="Insert more text"
icon="plus"
onClick={this.appendControlledText}
/>
<Tooltip2 content="Reset text" placement="bottom-end">
<AnchorButton disabled={!controlled} icon="reset" onClick={this.resetControlledText} />
</Tooltip2>
</ControlGroup>
</>
);
}
}