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
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>;
nurseiit marked this conversation as resolved.
Show resolved Hide resolved

/**
* 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}
/>
);
},
);
nurseiit marked this conversation as resolved.
Show resolved Hide resolved
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> {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: same comment about avoiding the I prefix

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) {
nurseiit marked this conversation as resolved.
Show resolved Hide resolved
// 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