Skip to content

Commit

Permalink
[core] add AsyncControllableTextArea based on AsyncControllableInput …
Browse files Browse the repository at this point in the history
…& add async control option for TextArea component (#6312)
  • Loading branch information
nurseiit authored Aug 23, 2023
1 parent 87fbf0c commit 55f525d
Show file tree
Hide file tree
Showing 4 changed files with 371 additions and 149 deletions.
43 changes: 43 additions & 0 deletions packages/core/src/components/forms/asyncControllableTextArea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/* !
* (c) Copyright 2023 Palantir Technologies Inc. All rights reserved.
*/

import * as React from "react";

import { useAsyncControllableValue } from "./useAsyncControllableValue";

export type IAsyncControllableTextAreaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;

/**
* A wrapper around the low-level <textarea> component which works around a React bug
* the same way <AsyncControllableInput> does.
*/
export const AsyncControllableTextArea = React.forwardRef<HTMLTextAreaElement, IAsyncControllableTextAreaProps>(
function _AsyncControllableTextArea(props, ref) {
const {
value: parentValue,
onChange: parentOnChange,
onCompositionStart: parentOnCompositionStart,
onCompositionEnd: parentOnCompositionEnd,
...restProps
} = props;

const { value, onChange, onCompositionStart, onCompositionEnd } = useAsyncControllableValue({
onChange: parentOnChange,
onCompositionEnd: parentOnCompositionEnd,
onCompositionStart: parentOnCompositionStart,
value: parentValue,
});

return (
<textarea
{...restProps}
value={value}
onChange={onChange}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
ref={ref}
/>
);
},
);
32 changes: 27 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 { AsyncControllableTextArea } from "./asyncControllableTextArea";

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,15 @@ export class TextArea extends AbstractPureComponent<TextAreaProps, TextAreaState
};
}

const TextAreaComponent = asyncControl ? AsyncControllableTextArea : "textarea";

return (
<textarea
<TextAreaComponent
{...htmlProps}
className={rootClasses}
onChange={this.handleChange}
ref={this.handleRef}
style={style}
ref={this.handleRef}
/>
);
}
Expand Down
133 changes: 133 additions & 0 deletions packages/core/src/components/forms/useAsyncControllableValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/* !
* (c) Copyright 2023 Palantir Technologies Inc. All rights reserved.
*/

import * as React from "react";

interface IUseAsyncControllableValueProps<E extends HTMLInputElement | HTMLTextAreaElement> {
value?: React.InputHTMLAttributes<E>["value"];
onChange?: React.ChangeEventHandler<E>;
onCompositionStart?: React.CompositionEventHandler<E>;
onCompositionEnd?: React.CompositionEventHandler<E>;
}

/**
* The amount of time (in milliseconds) which the input will wait after a compositionEnd event before
* unlocking its state value for external updates via props. See `handleCompositionEnd` for more details.
*/
export const ASYNC_CONTROLLABLE_VALUE_COMPOSITION_END_DELAY = 10;

/*
* A hook to workaround the following [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.
*/
export function useAsyncControllableValue<E extends HTMLInputElement | HTMLTextAreaElement>(
props: IUseAsyncControllableValueProps<E>,
) {
const { onCompositionStart, onCompositionEnd, value: propValue, onChange } = props;

// The source of truth for the input value. This is not updated during IME composition.
// It may be updated by a parent component.
const [value, setValue] = React.useState(propValue);

// The latest input value, which updates during IME composition.
const [nextValue, setNextValue] = React.useState(propValue);

// Whether we are in the middle of a composition event.
const [isComposing, setIsComposing] = React.useState(false);

// Whether there is a pending update we are expecting from a parent component.
const [hasPendingUpdate, setHasPendingUpdate] = React.useState(false);

const cancelPendingCompositionEnd = React.useRef<() => void>();

const handleCompositionStart: React.CompositionEventHandler<E> = React.useCallback(
event => {
cancelPendingCompositionEnd.current?.();
setIsComposing(true);
onCompositionStart?.(event);
},
[onCompositionStart],
);

// creates a timeout which will set `isComposing` to false after a delay
// returns a function which will cancel the timeout if called before it fires
const createOnCancelPendingCompositionEnd = React.useCallback(() => {
const timeoutId = window.setTimeout(
() => setIsComposing(false),
ASYNC_CONTROLLABLE_VALUE_COMPOSITION_END_DELAY,
);
return () => window.clearTimeout(timeoutId);
}, []);

const handleCompositionEnd: React.CompositionEventHandler<E> = React.useCallback(
event => {
// 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.
// `this.state.nextValue` will become "locked" (it cannot be overwritten by the `value` prop) until a delay (10ms) has
// passed without a new composition event starting.
cancelPendingCompositionEnd.current = createOnCancelPendingCompositionEnd();
onCompositionEnd?.(event);
},
[createOnCancelPendingCompositionEnd, onCompositionEnd],
);

const handleChange: React.ChangeEventHandler<E> = React.useCallback(
event => {
const { value: targetValue } = event.target;
setNextValue(targetValue);
onChange?.(event);
},
[onChange],
);

// don't derive anything from props if:
// - in uncontrolled mode, OR
// - currently composing, since we'll do that after composition ends
const shouldDeriveFromProps = !(isComposing || propValue === undefined);

if (shouldDeriveFromProps) {
const userTriggeredUpdate = nextValue !== value;

if (userTriggeredUpdate && propValue === nextValue) {
// parent has processed and accepted our update
setValue(propValue);
setHasPendingUpdate(false);
} else if (userTriggeredUpdate && propValue === value) {
// we have sent the update to our parent, but it has not been processed yet. just wait.
// DO NOT set nextValue here, since that will temporarily render a potentially stale controlled value,
// causing the cursor to jump once the new value is accepted
if (!hasPendingUpdate) {
// make sure to setState only when necessary to avoid infinite loops
setHasPendingUpdate(true);
}
} else if (userTriggeredUpdate && propValue !== value) {
// accept controlled update overriding user action
setValue(propValue);
setNextValue(propValue);
setHasPendingUpdate(false);
} else if (!userTriggeredUpdate) {
// accept controlled update, could be confirming or denying user action
if (value !== propValue || hasPendingUpdate) {
// make sure to setState only when necessary to avoid infinite loops
setValue(propValue);
setNextValue(propValue);
setHasPendingUpdate(false);
}
}
}

return {
onChange: handleChange,
onCompositionEnd: handleCompositionEnd,
onCompositionStart: handleCompositionStart,
// 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,
};
}
Loading

1 comment on commit 55f525d

@adidahiya
Copy link
Contributor

Choose a reason for hiding this comment

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

[core] add AsyncControllableTextArea based on AsyncControllableInput & add async control option for TextArea component (#6312)

Build artifact links for this commit: documentation | landing | table | demo

This is an automated comment from the deploy-preview CircleCI job.

Please sign in to comment.