Repository: uber-web/baseui (js)

Issue:
Bugs: multiple Inputs with clearable 
<!--- Provide a general summary of the issue in the Title above -->
<!--- Provide a codesandbox that reproduces your issue - you can fork one from  https://baseweb.design/ -->

## Current Behavior
If there are multiple inputs with clearable, only the last input will be cleared no mater which clear icon we click

Follow the code here.
```
import React, {useState} from 'react';

import {Block} from 'baseui/block';
import {Input, SIZE as INPUT_ZISE} from 'baseui/input';

export default () => {
  const [name, setName] = useState(undefined);
  const [submitter, setSubmitter] = useState(undefined);
  return (
   <div>
     <Input
          onChange={e => setName(e.target.value)}
          placeholder="Simulation Name"
          size={INPUT_ZISE.compact}
          value={name}
          clearable
        />
        <Input
          placeholder="Submitter"
          onChange={e => setSubmitter(e.target.value)}
          size={INPUT_ZISE.compact}
          value={submitter}
          clearable
        />
     </div>
  );
};
```
<!---
    Describe what happens instead of the expected behavior.
-->

## Expected Behavior

<!---
    Describe what should happen.
-->

## Your Environment

<!--- Include as many relevant details about the environment with which you experienced the bug. -->

| Tech    | Version |
| ------- | ------- |
| Base UI | v8.11.0  |
| React   |         |
| browser |         |

- [ ] I have searched the [issues](https://github.com/uber-web/baseui/issues) of this repository and believe that this is not a duplicate.


Pull request:
fix(input): clear and focus correct input when using clearable prop
Fixes #1643 

#### Description

This PR updates `Input` and `Textarea` so that they create their own internal `inputRef`s rather than sharing a single instance via the static `defaultProps` property.

##### The problem

When a user clears an input, the last input on the page is cleared and focused.

![lastinputgetscleared](https://user-images.githubusercontent.com/1939257/61985000-e4918d00-afbb-11e9-9ffb-8d08edb2de45.gif)

##### The cause

The issue here is that every mounted `Input` is sharing the value returned by `React.createRef()` in `defaultProps`. This makes every instance of `Input` (that does not receive `inputRef`) point to the last `Input` rendered via `inputRef.current`.

```jsx
class Input extends React.Component {
  defaultProps = {
    inputRef: React.createRef(), // called once, reference shared between all Inputs
  };
}
```
`clearable` relies on `this.props.inputRef` in order to manage focus and create the clear value event on the input element. When the `Input` internally calls `this.props.inputRef.current`, it references the last `Input` on the page to assign `inputRef` to `ref`.

I'm guessing this was never caught because, until recently, `autoFocus` was the only prop actually using `inputRef`. You aren't likely to have multiple inputs with `autoFocus` mounted simultaneously so the issue never shows up. `clearable` makes the issue quite apparent however.


Patch:
diff --git a/src/input/base-input.js b/src/input/base-input.js
index 68cbbf537..9b9aeef90 100644
--- a/src/input/base-input.js
+++ b/src/input/base-input.js
@@ -42,7 +42,6 @@ class BaseInput<T: EventTarget> extends React.Component<
     positive: false,
     name: '',
     inputMode: 'text',
-    inputRef: (React.createRef(): {current: HTMLInputElement | null}),
     onBlur: () => {},
     onChange: () => {},
     onKeyDown: () => {},
@@ -59,36 +58,41 @@ class BaseInput<T: EventTarget> extends React.Component<
     type: 'text',
   };
 
+  // eslint-disable-next-line flowtype/no-weak-types
+  inputRef = this.props.inputRef || React.createRef<any>();
+
   state = {
     isFocused: this.props.autoFocus || false,
     isMasked: this.props.type === 'password',
   };
 
   componentDidMount() {
-    const {inputRef, autoFocus, clearable} = this.props;
-    if (inputRef.current) {
+    const {autoFocus, clearable} = this.props;
+    if (this.inputRef.current) {
       if (autoFocus) {
-        inputRef.current.focus();
+        this.inputRef.current.focus();
       }
       if (clearable) {
-        inputRef.current.addEventListener('keydown', this.onInputKeyDown);
+        this.inputRef.current.addEventListener('keydown', this.onInputKeyDown);
       }
     }
   }
 
   componentWillUnmount() {
-    const {inputRef, clearable} = this.props;
-    if (clearable && inputRef.current) {
-      inputRef.current.removeEventListener('keydown', this.onInputKeyDown);
+    const {clearable} = this.props;
+    if (clearable && this.inputRef.current) {
+      this.inputRef.current.removeEventListener('keydown', this.onInputKeyDown);
     }
   }
 
   clearValue() {
     // trigger a fake input change event (as if all text was deleted)
-    const input = this.props.inputRef.current;
+    const input = this.inputRef.current;
     if (input) {
       const nativeInputValue = Object.getOwnPropertyDescriptor(
-        window.HTMLInputElement.prototype,
+        this.props.type === CUSTOM_INPUT_TYPE.textarea
+          ? window.HTMLTextAreaElement.prototype
+          : window.HTMLInputElement.prototype,
         'value',
       );
       if (nativeInputValue) {
@@ -102,12 +106,8 @@ class BaseInput<T: EventTarget> extends React.Component<
     }
   }
 
-  onInputKeyDown = (e: SyntheticKeyboardEvent<T>) => {
-    if (
-      this.props.clearable &&
-      e.key === 'Escape' &&
-      this.props.inputRef.current
-    ) {
+  onInputKeyDown = (e: KeyboardEvent) => {
+    if (this.props.clearable && e.key === 'Escape' && this.inputRef.current) {
       this.clearValue();
       // prevent event from closing modal or doing something unexpected
       e.stopPropagation();
@@ -115,11 +115,9 @@ class BaseInput<T: EventTarget> extends React.Component<
   };
 
   onClearIconClick = () => {
-    if (this.props.inputRef.current) {
-      this.clearValue();
-      // return focus to the input after click
-      this.props.inputRef.current.focus();
-    }
+    if (this.inputRef.current) this.clearValue();
+    // return focus to the input after click
+    if (this.inputRef.current) this.inputRef.current.focus();
   };
 
   onFocus = (e: SyntheticFocusEvent<T>) => {
@@ -267,7 +265,7 @@ class BaseInput<T: EventTarget> extends React.Component<
       >
         <Before {...sharedProps} {...beforeProps} />
         <Input
-          ref={this.props.inputRef}
+          ref={this.inputRef}
           aria-label={this.props['aria-label']}
           aria-labelledby={this.props['aria-labelledby']}
           aria-describedby={this.props['aria-describedby']}
diff --git a/src/input/types.js b/src/input/types.js
index 6a8ea877b..128278366 100644
--- a/src/input/types.js
+++ b/src/input/types.js
@@ -103,7 +103,7 @@ export type BaseInputPropsT<T> = {
   /** A  hint as to the type of data that might be entered by the user while editing the element or its contents. */
   inputMode: string,
   /** A ref to access an input element. */
-  inputRef: React.ElementRef<*>,
+  inputRef?: React.ElementRef<*>,
   name: string,
   onBlur: (e: SyntheticFocusEvent<T>) => mixed,
   onChange?: (e: SyntheticInputEvent<T>) => mixed,
diff --git a/src/textarea/textarea.js b/src/textarea/textarea.js
index a2711cc17..3e723dde6 100644
--- a/src/textarea/textarea.js
+++ b/src/textarea/textarea.js
@@ -16,7 +16,6 @@ class Textarea extends React.Component<TextareaPropsT> {
     autoFocus: false,
     disabled: false,
     error: false,
-    inputRef: (React.createRef(): {current: HTMLInputElement | null}),
     name: '',
     onBlur: () => {},
     onChange: () => {},


Test patch:
diff --git a/src/input/__tests__/input-clearable.scenario.js b/src/input/__tests__/input-clearable.scenario.js
index d99e41efc..266694845 100644
--- a/src/input/__tests__/input-clearable.scenario.js
+++ b/src/input/__tests__/input-clearable.scenario.js
@@ -13,9 +13,31 @@ import {StatefulInput} from '../index.js';
 export const name = 'input-clearable';
 
 export const component = () => (
-  <StatefulInput
-    clearable
-    initialState={{value: 'Something'}}
-    overrides={{Input: {props: {'data-test': 'e2e'}}}}
-  />
+  <>
+    <StatefulInput clearable initialState={{value: 'Some'}} size="compact" />
+    <br />
+    <StatefulInput
+      clearable
+      initialState={{value: 'Thing'}}
+      overrides={{
+        Input: {
+          props: {'data-e2e': 'input'},
+        },
+        ClearIcon: {
+          props: {'data-e2e': 'clear-icon'},
+        },
+      }}
+    />
+    <br />
+    <StatefulInput
+      clearable
+      initialState={{value: 'Or other'}}
+      overrides={{
+        Input: {
+          props: {'data-e2e': 'last-input'},
+        },
+      }}
+      size="large"
+    />
+  </>
 );
diff --git a/src/input/__tests__/input.e2e.js b/src/input/__tests__/input.e2e.js
index ce6b4f9a6..12bb9bc6f 100644
--- a/src/input/__tests__/input.e2e.js
+++ b/src/input/__tests__/input.e2e.js
@@ -11,8 +11,9 @@ LICENSE file in the root directory of this source tree.
 const {mount, analyzeAccessibility} = require('../../../e2e/helpers');
 
 const selectors = {
-  input: 'input[data-test="e2e"]',
-  deleteIcon: 'svg[role="button"]',
+  input: '[data-e2e="input"]',
+  clearIcon: '[data-e2e="clear-icon"]',
+  lastInput: '[data-e2e="last-input"]',
 };
 
 describe('input', () => {
@@ -43,7 +44,7 @@ describe('input', () => {
   describe('can clear values', () => {
     it('shows a clear value icon', async () => {
       await mount(page, 'input-clearable');
-      await page.waitFor(selectors.deleteIcon, {
+      await page.waitFor(selectors.clearIcon, {
         visible: true,
       });
     });
@@ -53,7 +54,7 @@ describe('input', () => {
       await page.waitFor(selectors.input);
 
       let inputValue = await page.$eval(selectors.input, input => input.value);
-      expect(inputValue).toBe('Something');
+      expect(inputValue).toBe('Thing');
 
       await page.focus(selectors.input);
       await page.keyboard.press('Escape');
@@ -61,7 +62,7 @@ describe('input', () => {
       inputValue = await page.$eval(selectors.input, input => input.value);
       expect(inputValue).toBe('');
 
-      await page.waitFor(selectors.deleteIcon, {
+      await page.waitFor(selectors.clearIcon, {
         hidden: true,
       });
     });
@@ -71,16 +72,44 @@ describe('input', () => {
       await page.waitFor(selectors.input);
 
       let inputValue = await page.$eval(selectors.input, input => input.value);
-      expect(inputValue).toBe('Something');
+      expect(inputValue).toBe('Thing');
 
-      await page.click(selectors.deleteIcon);
+      await page.click(selectors.clearIcon);
 
       inputValue = await page.$eval(selectors.input, input => input.value);
       expect(inputValue).toBe('');
 
-      await page.waitFor(selectors.deleteIcon, {
+      await page.waitFor(selectors.clearIcon, {
         hidden: true,
       });
     });
+
+    // regression test for https://github.com/uber-web/baseui/issues/1643
+    // verify that the input receiving the clear event is cleared and not another input
+    it('clears the correct input', async () => {
+      await mount(page, 'input-clearable');
+      await page.waitFor(selectors.input);
+
+      // verify first input value is "Thing"
+      expect(await page.$eval(selectors.input, input => input.value)).toBe(
+        'Thing',
+      );
+
+      // verify second input value is "Or other"
+      expect(await page.$eval(selectors.lastInput, input => input.value)).toBe(
+        'Or other',
+      );
+
+      // clear first input
+      await page.click(selectors.clearIcon);
+
+      // verify first input value is ""
+      expect(await page.$eval(selectors.input, input => input.value)).toBe('');
+
+      // verify second input value is still "Other"
+      expect(await page.$eval(selectors.lastInput, input => input.value)).toBe(
+        'Or other',
+      );
+    });
   });
 });
diff --git a/src/input/__tests__/input.scenario.js b/src/input/__tests__/input.scenario.js
index cc291df26..034b84602 100644
--- a/src/input/__tests__/input.scenario.js
+++ b/src/input/__tests__/input.scenario.js
@@ -21,6 +21,6 @@ export const component = () => (
     startEnhancer="@"
     endEnhancer=".com"
     size={SIZE.compact}
-    overrides={{Input: {props: {'data-test': 'e2e'}}}}
+    overrides={{Input: {props: {'data-e2e': 'input'}}}}
   />
 );
diff --git a/src/textarea/__tests__/__snapshots__/stateful-textarea.test.js.snap b/src/textarea/__tests__/__snapshots__/stateful-textarea.test.js.snap
index 030ff6c7d..9de33c5c3 100644
--- a/src/textarea/__tests__/__snapshots__/stateful-textarea.test.js.snap
+++ b/src/textarea/__tests__/__snapshots__/stateful-textarea.test.js.snap
@@ -21,11 +21,6 @@ exports[`StatefulTextarea basic render: renders <Textarea/> as a child 1`] = `
   autoFocus={false}
   disabled={false}
   error={false}
-  inputRef={
-    Object {
-      "current": null,
-    }
-  }
   name=""
   onBlur={[Function]}
   onChange={[Function]}
diff --git a/src/textarea/__tests__/__snapshots__/textarea.test.js.snap b/src/textarea/__tests__/__snapshots__/textarea.test.js.snap
index af821f721..160fe3f0b 100644
--- a/src/textarea/__tests__/__snapshots__/textarea.test.js.snap
+++ b/src/textarea/__tests__/__snapshots__/textarea.test.js.snap
@@ -65,22 +65,6 @@ Object {
   "disabled": false,
   "error": false,
   "inputMode": "text",
-  "inputRef": Object {
-    "current": <textarea
-      aria-invalid="false"
-      aria-required="false"
-      autocomplete="on"
-      inputmode="text"
-      name=""
-      placeholder="Placeholder"
-      rows="3"
-      styled-component="true"
-      test-style="[object Object]"
-      type="textarea"
-    >
-      textarea value
-    </textarea>,
-  },
   "name": "",
   "onBlur": [MockFunction],
   "onChange": [MockFunction],
@@ -127,26 +111,6 @@ Object {
   "disabled": false,
   "error": false,
   "inputMode": "text",
-  "inputRef": Object {
-    "current": <span
-      id="test-input"
-    >
-      <textarea
-        aria-invalid="false"
-        aria-required="false"
-        autocomplete="on"
-        inputmode="text"
-        name=""
-        placeholder="Placeholder"
-        rows="3"
-        styled-component="true"
-        test-style="[object Object]"
-        type="textarea"
-      >
-        textarea value
-      </textarea>
-    </span>,
-  },
   "name": "",
   "onBlur": [MockFunction],
   "onChange": [MockFunction],
diff --git a/src/textarea/__tests__/textarea.e2e.js b/src/textarea/__tests__/textarea.e2e.js
index e047f96e0..5625fcf1b 100644
--- a/src/textarea/__tests__/textarea.e2e.js
+++ b/src/textarea/__tests__/textarea.e2e.js
@@ -12,6 +12,7 @@ const {mount, analyzeAccessibility} = require('../../../e2e/helpers');
 
 const selectors = {
   textarea: 'textarea',
+  clearIcon: '[data-e2e="clear-icon"]',
 };
 
 describe('textarea', () => {
@@ -46,4 +47,24 @@ describe('textarea', () => {
     const value = await page.$eval(selectors.textarea, input => input.value);
     expect(value).toBe('initial value!');
   });
+
+  it('can be cleared with a click', async () => {
+    await mount(page, 'textarea');
+    await page.waitFor(selectors.textarea);
+    await page.click(selectors.textarea);
+    await page.keyboard.type('Something or other');
+    await page.click(selectors.clearIcon);
+    const value = await page.$eval(selectors.textarea, input => input.value);
+    expect(value).toBe('');
+  });
+
+  it('can be cleared with escape', async () => {
+    await mount(page, 'textarea');
+    await page.waitFor(selectors.textarea);
+    await page.click(selectors.textarea);
+    await page.keyboard.type('Something or other');
+    await page.keyboard.press('Escape');
+    const value = await page.$eval(selectors.textarea, input => input.value);
+    expect(value).toBe('');
+  });
 });
diff --git a/src/textarea/__tests__/textarea.scenario.js b/src/textarea/__tests__/textarea.scenario.js
index 8c6bac90e..c56266deb 100644
--- a/src/textarea/__tests__/textarea.scenario.js
+++ b/src/textarea/__tests__/textarea.scenario.js
@@ -14,7 +14,13 @@ export const name = 'textarea';
 
 export const component = () => (
   <StatefulTextarea
+    clearable
     placeholder="Uncontrolled textarea"
     initialState={{value: 'initial value'}}
+    overrides={{
+      ClearIcon: {
+        props: {'data-e2e': 'clear-icon'},
+      },
+    }}
   />
 );