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] add AsyncControllableTextArea based on AsyncControllableInput & add async control option for TextArea component #6312

Merged
merged 13 commits into from
Aug 23, 2023
60 changes: 29 additions & 31 deletions packages/core/src/components/forms/asyncControllableInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@
import * as React from "react";

import { AbstractPureComponent, DISPLAYNAME_PREFIX } from "../../common";
import {
AsyncControllableElement,
AsyncControllableInputProps,
AsyncControllableInputValue,
InputTagName,
} from "./asyncControllableInputProps";

export type AsyncControllableInputProps = React.InputHTMLAttributes<HTMLInputElement> & {
inputRef?: React.Ref<HTMLInputElement>;
};

type InputValue = AsyncControllableInputProps["value"];

export interface AsyncControllableInputState {
export interface AsyncControllableInputState<T extends InputTagName = "input"> {
/**
* Whether we are in the middle of a composition event.
*
Expand All @@ -38,12 +38,12 @@ export interface AsyncControllableInputState {
*
* @default ""
*/
value: InputValue;
value: AsyncControllableInputValue<T>;

/**
* The latest input value, which updates during IME composition. Defaults to props.value.
*/
nextValue: InputValue;
nextValue: AsyncControllableInputValue<T>;

/**
* Whether there is a pending update we are expecting from a parent component.
Expand All @@ -54,17 +54,17 @@ export interface AsyncControllableInputState {
}

/**
* A stateful wrapper around the low-level <input> component which works around a
* A stateful wrapper around the low-level <input> or <textarea> components which works around a
* [React bug](https://github.com/facebook/react/issues/3926). This bug is reproduced when an input
* receives CompositionEvents (for example, through IME composition) and has its value prop updated
* asychronously. This might happen if a component chooses to do async validation of a value
* returned by the input's `onChange` callback.
*
* Note: this component does not apply any Blueprint-specific styling.
*/
export class AsyncControllableInput extends AbstractPureComponent<
AsyncControllableInputProps,
AsyncControllableInputState
export class AsyncControllableInput<T extends InputTagName = "input"> extends AbstractPureComponent<
AsyncControllableInputProps<T>,
AsyncControllableInputState<T>
> {
public static displayName = `${DISPLAYNAME_PREFIX}.AsyncControllableInput`;

Expand All @@ -74,7 +74,7 @@ export class AsyncControllableInput extends AbstractPureComponent<
*/
public static COMPOSITION_END_DELAY = 10;

public state: AsyncControllableInputState = {
public state: AsyncControllableInputState<T> = {
hasPendingUpdate: false,
isComposing: false,
nextValue: this.props.value,
Expand Down Expand Up @@ -122,29 +122,27 @@ export class AsyncControllableInput extends AbstractPureComponent<

public render() {
const { isComposing, hasPendingUpdate, value, nextValue } = this.state;
const { inputRef, ...restProps } = this.props;
return (
<input
{...restProps}
ref={inputRef}
// render the pending value even if it is not confirmed by a parent's async controlled update
// so that the cursor does not jump to the end of input as reported in
// https://github.com/palantir/blueprint/issues/4298
value={isComposing || hasPendingUpdate ? nextValue : value}
onCompositionStart={this.handleCompositionStart}
onCompositionEnd={this.handleCompositionEnd}
onChange={this.handleChange}
/>
);
const { inputRef, tagName = "input", ...restProps } = this.props;
return React.createElement(tagName, {
...restProps,
onChange: this.handleChange,
onCompositionEnd: this.handleCompositionEnd,
onCompositionStart: this.handleCompositionStart,
ref: inputRef,
// render the pending value even if it is not confirmed by a parent's async controlled update
// so that the cursor does not jump to the end of input as reported in
// https://github.com/palantir/blueprint/issues/4298
value: isComposing || hasPendingUpdate ? nextValue : value,
});
}

private handleCompositionStart = (e: React.CompositionEvent<HTMLInputElement>) => {
private handleCompositionStart = (e: React.CompositionEvent<AsyncControllableElement<T>>) => {
this.cancelPendingCompositionEnd?.();
this.setState({ isComposing: true });
this.props.onCompositionStart?.(e);
};

private handleCompositionEnd = (e: React.CompositionEvent<HTMLInputElement>) => {
private handleCompositionEnd = (e: React.CompositionEvent<AsyncControllableElement<T>>) => {
// In some non-latin languages, a keystroke can end a composition event and immediately afterwards start another.
// This can lead to unexpected characters showing up in the text input. In order to circumvent this problem, we
// use a timeout which creates a delay which merges the two composition events, creating a more natural and predictable UX.
Expand All @@ -157,7 +155,7 @@ export class AsyncControllableInput extends AbstractPureComponent<
this.props.onCompositionEnd?.(e);
};

private handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
private handleChange = (e: React.ChangeEvent<AsyncControllableElement<T>>) => {
const { value } = e.target;

this.setState({ nextValue: value });
Expand Down
38 changes: 38 additions & 0 deletions packages/core/src/components/forms/asyncControllableInputProps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/* !
* (c) Copyright 2023 Palantir Technologies Inc. All rights reserved.
*/

export type InputTagName = "input" | "textarea";

export type AsyncControllableElement<T extends InputTagName> = T extends "input"
? HTMLInputElement
: T extends "textarea"
? HTMLTextAreaElement
: never;

type AsyncControllableElementAttributes<T extends InputTagName> = T extends "input"
? React.InputHTMLAttributes<HTMLInputElement>
: T extends "textarea"
? React.TextareaHTMLAttributes<HTMLTextAreaElement>
: never;

export type AsyncControllableInputValue<T extends InputTagName> = AsyncControllableElementAttributes<T>["value"];

export type AsyncControllableInputProps<T extends InputTagName = "input"> = Omit<
AsyncControllableElementAttributes<T>,
"onChange" | "onCompositionStart" | "onCompositionEnd"
> & {
/**
* HTML tag name to use for rendered input element.
*
* @type {"input" | "textarea"}
* @default "input"
*/
tagName?: T;
inputRef?: React.Ref<AsyncControllableElement<T>>;

// NOTE: these are copied from the React.HTMLAttributes interface definition.
onChange?: React.ChangeEventHandler<AsyncControllableElement<T>> | undefined;
onCompositionStart?: React.CompositionEventHandler<AsyncControllableElement<T>> | undefined;
onCompositionEnd?: React.CompositionEventHandler<AsyncControllableElement<T>> | undefined;
Copy link
Member Author

Choose a reason for hiding this comment

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

not sure if this is the best way to do it, but I would argue this is fine since this component is not exported directly.

Copy link
Member Author

Choose a reason for hiding this comment

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

I did not want to further complicate the types by excluding and extending the native interfaces above.

};
39 changes: 34 additions & 5 deletions packages/core/src/components/forms/textArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,18 @@ import * as React from "react";

import { AbstractPureComponent, Classes, refHandler, setRef } from "../../common";
import { DISPLAYNAME_PREFIX, IntentProps, Props } from "../../common/props";
import { AsyncControllableInput } from "./asyncControllableInput";

export interface TextAreaProps extends IntentProps, Props, React.TextareaHTMLAttributes<HTMLTextAreaElement> {
/**
* Set this to `true` if you will be controlling the `value` of this input with asynchronous updates.
* These may occur if you do not immediately call setState in a parent component with the value from
* the `onChange` handler, or if working with certain libraries like __redux-form__.
*
* @default false
*/
asyncControl?: boolean;

/**
* Whether the component should automatically resize vertically as a user types in the text input.
* This will disable manual resizing in the vertical dimension.
Expand Down Expand Up @@ -142,9 +152,19 @@ export class TextArea extends AbstractPureComponent<TextAreaProps, TextAreaState
}

public render() {
// eslint-disable-next-line deprecation/deprecation
const { autoResize, className, fill, growVertically, inputRef, intent, large, small, ...htmlProps } =
this.props;
const {
asyncControl,
autoResize,
className,
fill,
// eslint-disable-next-line deprecation/deprecation
growVertically,
inputRef,
intent,
large,
small,
...htmlProps
} = this.props;

const rootClasses = classNames(
Classes.INPUT,
Expand All @@ -170,13 +190,22 @@ export class TextArea extends AbstractPureComponent<TextAreaProps, TextAreaState
};
}

return (
return asyncControl ? (
Copy link
Contributor

Choose a reason for hiding this comment

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

Does this seem to work when testing live? The code changes look correct. Just wondering if <TextArea asyncControl /> works as expected.

<AsyncControllableInput
{...htmlProps}
tagName="textarea"
className={rootClasses}
onChange={this.handleChange}
style={style}
inputRef={this.handleRef}
/>
) : (
<textarea
{...htmlProps}
className={rootClasses}
onChange={this.handleChange}
ref={this.handleRef}
style={style}
ref={this.handleRef}
/>
);
}
Expand Down
Loading