UNPKG

6.03 kBTypeScriptView Raw
1/* !
2 * Copyright 2020 Palantir Technologies, Inc. All rights reserved.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17import * as React from "react";
18import { polyfill } from "react-lifecycles-compat";
19
20import { DISPLAYNAME_PREFIX } from "../../common/props";
21
22export interface IAsyncControllableInputProps
23 extends React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> {
24 inputRef?: React.LegacyRef<HTMLInputElement>;
25}
26
27type InputValue = IAsyncControllableInputProps["value"];
28
29export interface IAsyncControllableInputState {
30 /**
31 * Whether we are in the middle of a composition event.
32 *
33 * @default false
34 */
35 isComposing: boolean;
36
37 /**
38 * The source of truth for the input value. This is not updated during IME composition.
39 * It may be updated by a parent component.
40 *
41 * @default ""
42 */
43 value: InputValue;
44
45 /**
46 * The latest input value, which updates during IME composition. Defaults to props.value.
47 */
48 nextValue: InputValue;
49
50 /**
51 * Whether there is a pending update we are expecting from a parent component.
52 *
53 * @default false
54 */
55 hasPendingUpdate: boolean;
56}
57
58/**
59 * A stateful wrapper around the low-level <input> component which works around a
60 * [React bug](https://github.com/facebook/react/issues/3926). This bug is reproduced when an input
61 * receives CompositionEvents (for example, through IME composition) and has its value prop updated
62 * asychronously. This might happen if a component chooses to do async validation of a value
63 * returned by the input's `onChange` callback.
64 *
65 * Note: this component does not apply any Blueprint-specific styling.
66 */
67@polyfill
68export class AsyncControllableInput extends React.PureComponent<
69 IAsyncControllableInputProps,
70 IAsyncControllableInputState
71> {
72 public static displayName = `${DISPLAYNAME_PREFIX}.AsyncControllableInput`;
73
74 public state: IAsyncControllableInputState = {
75 hasPendingUpdate: false,
76 isComposing: false,
77 nextValue: this.props.value,
78 value: this.props.value,
79 };
80
81 public static getDerivedStateFromProps(
82 nextProps: IAsyncControllableInputProps,
83 nextState: IAsyncControllableInputState,
84 ): Partial<IAsyncControllableInputState> | null {
85 if (nextState.isComposing || nextProps.value === undefined) {
86 // don't derive anything from props if:
87 // - in uncontrolled mode, OR
88 // - currently composing, since we'll do that after composition ends
89 return null;
90 }
91
92 const userTriggeredUpdate = nextState.nextValue !== nextState.value;
93
94 if (userTriggeredUpdate) {
95 if (nextProps.value === nextState.nextValue) {
96 // parent has processed and accepted our update
97 if (nextState.hasPendingUpdate) {
98 return { value: nextProps.value, hasPendingUpdate: false };
99 } else {
100 return { value: nextState.nextValue };
101 }
102 } else {
103 if (nextProps.value === nextState.value) {
104 // we have sent the update to our parent, but it has not been processed yet. just wait.
105 // DO NOT set nextValue here, since that will temporarily render a potentially stale controlled value,
106 // causing the cursor to jump once the new value is accepted
107 return { hasPendingUpdate: true };
108 }
109 // accept controlled update overriding user action
110 return { value: nextProps.value, nextValue: nextProps.value, hasPendingUpdate: false };
111 }
112 } else {
113 // accept controlled update, could be confirming or denying user action
114 return { value: nextProps.value, nextValue: nextProps.value, hasPendingUpdate: false };
115 }
116 }
117
118 public render() {
119 const { isComposing, hasPendingUpdate, value, nextValue } = this.state;
120 const { inputRef, ...restProps } = this.props;
121 return (
122 <input
123 {...restProps}
124 ref={inputRef}
125 // render the pending value even if it is not confirmed by a parent's async controlled update
126 // so that the cursor does not jump to the end of input as reported in
127 // https://github.com/palantir/blueprint/issues/4298
128 value={isComposing || hasPendingUpdate ? nextValue : value}
129 onCompositionStart={this.handleCompositionStart}
130 onCompositionEnd={this.handleCompositionEnd}
131 onChange={this.handleChange}
132 />
133 );
134 }
135
136 private handleCompositionStart = (e: React.CompositionEvent<HTMLInputElement>) => {
137 this.setState({
138 isComposing: true,
139 // Make sure that localValue matches externalValue, in case externalValue
140 // has changed since the last onChange event.
141 nextValue: this.state.value,
142 });
143 this.props.onCompositionStart?.(e);
144 };
145
146 private handleCompositionEnd = (e: React.CompositionEvent<HTMLInputElement>) => {
147 this.setState({ isComposing: false });
148 this.props.onCompositionEnd?.(e);
149 };
150
151 private handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
152 const { value } = e.target;
153
154 this.setState({ nextValue: value });
155 this.props.onChange?.(e);
156 };
157}