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 |
|
17 | import * as React from "react";
|
18 | import { polyfill } from "react-lifecycles-compat";
|
19 |
|
20 | import { DISPLAYNAME_PREFIX } from "../../common/props";
|
21 |
|
22 | export interface IAsyncControllableInputProps
|
23 | extends React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> {
|
24 | inputRef?: React.LegacyRef<HTMLInputElement>;
|
25 | }
|
26 |
|
27 | type InputValue = IAsyncControllableInputProps["value"];
|
28 |
|
29 | export 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 |
|
68 | export 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 | }
|