Repository: grommet/grommet (JavaScript)

Issue:
TextInput makes it difficult to listen for select events on input element
`<TextInput />` has an `onSelect` prop used for the suggestions feature which overrides the `<input />`s `onSelect` prop for listening to [`select`](https://developer.mozilla.org/en-US/docs/Web/API/Element/select_event) events. This makes listening to `select` events on the input more difficult than it should be.

As a solution, I purpose changing the current `onSelect` prop to `onSuggestionSelect` and allow `onSelect` to resume it's default behaviour on the `<input />`.

### Expected Behavior

`<TextInput onSelect={/* ...  */} />` should add the provided function as an event handler for `select` events.

### Actual Behavior

Grommet overrides the `onSelect` prop for suggestion selection events.

### URL, screen shot, or Codepen exhibiting the issue

https://codesandbox.io/s/grommet-v2-template-5rcuj (includes a workaround).

### Steps to Reproduce

1. Define an `onSelect` event handler on a `<TextInput />`
2. Notice that it does not listen for `select` events on the `<input />`.

### Your Environment

<!--- Include as many relevant details about the environment you experienced the bug in -->

- Grommet version: 2.7.0



Discussion:
Im for having onSelect renamed to something else, so the default onSelect doesn't get overridden. If this is an acceptable solution, i can take this on.
@IanKBovard let's discuss this on on our call tomorrow.
@jvatic, thanks for reporting!
@IanKBovard next steps for this issue should be to create our own `onSuggestionSelected` call that will perform the same as our current `onSelect` function.
For backward compatibility, we will still accept `onSelect`, but in case the users will define `onSuggestionSelected` the `onSelect` will fallback to the [WebAPI](https://developer.mozilla.org/en-US/docs/Web/API/GlobalEventHandlers/onselec).
Let me know what you think.
@ShimiSun sounds good to me. Ill get started on it.



Patch:
diff --git a/src/js/components/TextInput/README.md b/src/js/components/TextInput/README.md
index e172fe10dd5..461cc1d5a79 100644
--- a/src/js/components/TextInput/README.md
+++ b/src/js/components/TextInput/README.md
@@ -137,8 +137,20 @@ function
 
 **onSelect**
 
+Note: This function is deprecated, use onSuggestionSelect instead.
+      Function that will be called when the user selects a suggestion.
+      The suggestion contains the object chosen from the supplied suggestions.
+      When used in conjunction with onSuggestionSelect 
+      this will default to React's onSelect
+
+```
+function
+```
+
+**onSuggestionSelect**
+
 Function that will be called when the user selects a suggestion.
-The suggestion contains the object chosen from the supplied suggestions.
+      The suggestion contains the object chosen from the supplied suggestions.
 
 ```
 function
@@ -171,7 +183,7 @@ node
 **plain**
 
 Whether this is a plain input with no border or padding.
-Only use this when the containing context provides sufficient affordance
+      Only use this when the containing context provides sufficient affordance
 
 ```
 boolean
diff --git a/src/js/components/TextInput/TextInput.js b/src/js/components/TextInput/TextInput.js
index e644a6a07fc..9a725617678 100644
--- a/src/js/components/TextInput/TextInput.js
+++ b/src/js/components/TextInput/TextInput.js
@@ -3,6 +3,7 @@ import React, {
   useCallback,
   useContext,
   useEffect,
+  useMemo,
   useRef,
   useState,
 } from 'react';
@@ -89,6 +90,7 @@ const TextInput = forwardRef(
       onFocus,
       onKeyDown,
       onSelect,
+      onSuggestionSelect,
       onSuggestionsClose,
       onSuggestionsOpen,
       placeholder,
@@ -108,7 +110,6 @@ const TextInput = forwardRef(
     const dropRef = useRef();
     const suggestionsRef = useRef();
     const suggestionRefs = {};
-
     // if this is a readOnly property, don't set a name with the form context
     // this allows Select to control the form context for the name.
     const [value, setValue] = formContext.useFormInput(
@@ -119,6 +120,15 @@ const TextInput = forwardRef(
     const [focus, setFocus] = useState();
     const [showDrop, setShowDrop] = useState();
 
+    const handleSuggestionSelect = useMemo(
+      () => (onSelect && !onSuggestionSelect ? onSelect : onSuggestionSelect),
+      [onSelect, onSuggestionSelect],
+    );
+    const handleTextSelect = useMemo(
+      () => (onSelect && onSuggestionSelect ? onSelect : undefined),
+      [onSelect, onSuggestionSelect],
+    );
+
     // if we have no suggestions, close drop if it's open
     useEffect(() => {
       if (showDrop && (!suggestions || !suggestions.length)) {
@@ -232,6 +242,9 @@ const TextInput = forwardRef(
       placeholder && typeof placeholder !== 'string' && !value;
 
     let drop;
+    const extraProps = {
+      onSelect: handleTextSelect,
+    };
     if (showDrop) {
       drop = (
         // keyboard access needed here in case user clicks
@@ -243,10 +256,10 @@ const TextInput = forwardRef(
             // we stole the focus, give it back
             inputRef.current.focus();
             closeDrop();
-            if (onSelect) {
+            if (handleSuggestionSelect) {
               const adjustedEvent = event;
               adjustedEvent.suggestion = suggestions[activeSuggestionIndex];
-              onSelect(adjustedEvent);
+              handleSuggestionSelect(adjustedEvent);
             }
             setValue(suggestions[activeSuggestionIndex]);
           }}
@@ -308,12 +321,12 @@ const TextInput = forwardRef(
                             // we stole the focus, give it back
                             inputRef.current.focus();
                             closeDrop();
-                            if (onSelect) {
+                            if (handleSuggestionSelect) {
                               event.persist();
                               const adjustedEvent = event;
                               adjustedEvent.suggestion = suggestion;
                               adjustedEvent.target = inputRef.current;
-                              onSelect(adjustedEvent);
+                              handleSuggestionSelect(adjustedEvent);
                             }
                             setValue(suggestion);
                           }}
@@ -346,14 +359,14 @@ const TextInput = forwardRef(
         <Keyboard
           onEnter={event => {
             closeDrop();
-            if (activeSuggestionIndex >= 0 && onSelect) {
+            if (activeSuggestionIndex >= 0 && handleSuggestionSelect) {
               // prevent submitting forms when choosing a suggestion
               event.preventDefault();
               event.persist();
               const adjustedEvent = event;
               adjustedEvent.suggestion = suggestions[activeSuggestionIndex];
               adjustedEvent.target = inputRef.current;
-              onSelect(adjustedEvent);
+              handleSuggestionSelect(adjustedEvent);
             }
           }}
           onEsc={
@@ -406,6 +419,7 @@ const TextInput = forwardRef(
             reverse={reverse}
             focus={focus}
             {...rest}
+            {...extraProps}
             defaultValue={renderLabel(defaultValue)}
             value={renderLabel(value)}
             readOnly={readOnly}
diff --git a/src/js/components/TextInput/doc.js b/src/js/components/TextInput/doc.js
index 073eacbd38d..876de64aff6 100644
--- a/src/js/components/TextInput/doc.js
+++ b/src/js/components/TextInput/doc.js
@@ -73,8 +73,15 @@ export const doc = TextInput => {
       'Function that will be called when the user types in the input.',
     ),
     onSelect: PropTypes.func.description(
+      `Note: This function is deprecated, use onSuggestionSelect instead.
+      Function that will be called when the user selects a suggestion.
+      The suggestion contains the object chosen from the supplied suggestions.
+      When used in conjunction with onSuggestionSelect 
+      this will default to React's onSelect`,
+    ),
+    onSuggestionSelect: PropTypes.func.description(
       `Function that will be called when the user selects a suggestion.
-The suggestion contains the object chosen from the supplied suggestions.`,
+      The suggestion contains the object chosen from the supplied suggestions.`,
     ),
     onSuggestionsOpen: PropTypes.func.description(
       'Function that will be called when the suggestions drop is opened.',
@@ -87,7 +94,7 @@ The suggestion contains the object chosen from the supplied suggestions.`,
     ),
     plain: PropTypes.bool.description(
       `Whether this is a plain input with no border or padding.
-Only use this when the containing context provides sufficient affordance`,
+      Only use this when the containing context provides sufficient affordance`,
     ),
     reverse: PropTypes.bool.description(
       `Whether an icon should be reversed so that the icon is at the
diff --git a/src/js/components/TextInput/stories/OnSelect.js b/src/js/components/TextInput/stories/OnSelect.js
new file mode 100644
index 00000000000..416ce0183ed
--- /dev/null
+++ b/src/js/components/TextInput/stories/OnSelect.js
@@ -0,0 +1,48 @@
+import React from 'react';
+import { storiesOf } from '@storybook/react';
+
+import { Box, Grommet, TextInput } from 'grommet';
+import { grommet } from 'grommet/themes';
+
+const suggestions = Array(100)
+  .fill()
+  .map((_, i) => `suggestion ${i + 1}`);
+
+const OnSelect = () => {
+  const [value, setValue] = React.useState('');
+
+  const onChange = event => setValue(event.target.value);
+
+  const onSelect = event => setValue(event.suggestion);
+
+  const onHighlight = event => {
+    if (event.target.selectionStart !== event.target.selectionEnd) {
+      console.log(
+        event.target.value.substring(
+          event.target.selectionStart,
+          event.target.selectionEnd,
+        ),
+      );
+    }
+  };
+
+  return (
+    <Grommet full theme={grommet}>
+      <Box fill align="center" justify="start" pad="large">
+        <Box width="medium">
+          <TextInput
+            value={value}
+            onChange={onChange}
+            onSelect={onHighlight}
+            onSuggestionSelect={onSelect}
+            suggestions={suggestions}
+          />
+        </Box>
+      </Box>
+    </Grommet>
+  );
+};
+
+storiesOf('TextInput', module).add('onSelect and onSuggestionSelect', () => (
+  <OnSelect />
+));


Test patch:
diff --git a/src/js/components/TextInput/__tests__/TextInput-test.js b/src/js/components/TextInput/__tests__/TextInput-test.js
index babe37c06fa..7a62bddcef6 100644
--- a/src/js/components/TextInput/__tests__/TextInput-test.js
+++ b/src/js/components/TextInput/__tests__/TextInput-test.js
@@ -262,7 +262,7 @@ describe('TextInput', () => {
     }, 50);
   });
 
-  test('select a suggestion', () => {
+  test('select a suggestion with onSelect', () => {
     const onSelect = jest.fn();
     const { getByTestId, container } = render(
       <Grommet>
@@ -291,6 +291,66 @@ describe('TextInput', () => {
     );
   });
 
+  test('select a suggestion with onSuggestionSelect', () => {
+    const onSuggestionSelect = jest.fn();
+    const { getByTestId, container } = render(
+      <Grommet>
+        <TextInput
+          data-testid="test-input"
+          id="item"
+          name="item"
+          suggestions={['test', { value: 'test1' }]}
+          onSuggestionSelect={onSuggestionSelect}
+        />
+      </Grommet>,
+    );
+    expect(container.firstChild).toMatchSnapshot();
+
+    const input = getByTestId('test-input');
+    // pressing enter here nothing will happen
+    fireEvent.keyDown(input, { keyCode: 13 }); // enter
+    fireEvent.keyDown(input, { keyCode: 40 }); // down
+    fireEvent.keyDown(input, { keyCode: 40 }); // down
+    fireEvent.keyDown(input, { keyCode: 38 }); // up
+    fireEvent.keyDown(input, { keyCode: 13 }); // enter
+    expect(onSuggestionSelect).toBeCalledWith(
+      expect.objectContaining({
+        suggestion: 'test',
+      }),
+    );
+  });
+
+  test('select with onSuggestionSelect when onSelect is present', () => {
+    const onSelect = jest.fn();
+    const onSuggestionSelect = jest.fn();
+    const { getByTestId, container } = render(
+      <Grommet>
+        <TextInput
+          data-testid="test-input"
+          id="item"
+          name="item"
+          suggestions={['test', { value: 'test1' }]}
+          onSelect={onSelect}
+          onSuggestionSelect={onSuggestionSelect}
+        />
+      </Grommet>,
+    );
+    expect(container.firstChild).toMatchSnapshot();
+
+    const input = getByTestId('test-input');
+    // pressing enter here nothing will happen
+    fireEvent.keyDown(input, { keyCode: 13 }); // enter
+    fireEvent.keyDown(input, { keyCode: 40 }); // down
+    fireEvent.keyDown(input, { keyCode: 40 }); // down
+    fireEvent.keyDown(input, { keyCode: 38 }); // up
+    fireEvent.keyDown(input, { keyCode: 13 }); // enter
+    expect(onSuggestionSelect).toBeCalledWith(
+      expect.objectContaining({
+        suggestion: 'test',
+      }),
+    );
+  });
+
   test('handles next and previous without suggestion', () => {
     const onSelect = jest.fn();
     const { getByTestId, container } = render(
diff --git a/src/js/components/TextInput/__tests__/__snapshots__/TextInput-test.js.snap b/src/js/components/TextInput/__tests__/__snapshots__/TextInput-test.js.snap
index 05edf20c04a..eac669c471d 100644
--- a/src/js/components/TextInput/__tests__/__snapshots__/TextInput-test.js.snap
+++ b/src/js/components/TextInput/__tests__/__snapshots__/TextInput-test.js.snap
@@ -2033,7 +2033,83 @@ exports[`TextInput medium drop height 1`] = `
 
 exports[`TextInput medium drop height 2`] = `""`;
 
-exports[`TextInput select a suggestion 1`] = `
+exports[`TextInput select a suggestion with onSelect 1`] = `
+.c0 {
+  font-size: 18px;
+  line-height: 24px;
+  box-sizing: border-box;
+  -webkit-text-size-adjust: 100%;
+  -ms-text-size-adjust: 100%;
+  -moz-osx-font-smoothing: grayscale;
+  -webkit-font-smoothing: antialiased;
+}
+
+.c2 {
+  box-sizing: border-box;
+  font-size: inherit;
+  font-family: inherit;
+  border: none;
+  -webkit-appearance: none;
+  background: transparent;
+  color: inherit;
+  width: 100%;
+  padding: 11px;
+  font-weight: 600;
+  margin: 0;
+  border: 1px solid rgba(0,0,0,0.33);
+  border-radius: 4px;
+}
+
+.c2::-webkit-input-placeholder {
+  color: #AAAAAA;
+}
+
+.c2::-moz-placeholder {
+  color: #AAAAAA;
+}
+
+.c2:-ms-input-placeholder {
+  color: #AAAAAA;
+}
+
+.c2::-webkit-search-decoration {
+  -webkit-appearance: none;
+}
+
+.c2::-moz-focus-inner {
+  border: none;
+  outline: none;
+}
+
+.c2:-moz-placeholder,
+.c2::-moz-placeholder {
+  opacity: 1;
+}
+
+.c1 {
+  position: relative;
+  width: 100%;
+}
+
+<div
+  class="c0"
+>
+  <div
+    class="c1"
+  >
+    <input
+      autocomplete="off"
+      class="c2"
+      data-testid="test-input"
+      id="item"
+      name="item"
+      value=""
+    />
+  </div>
+</div>
+`;
+
+exports[`TextInput select a suggestion with onSuggestionSelect 1`] = `
 .c0 {
   font-size: 18px;
   line-height: 24px;
@@ -2413,6 +2489,82 @@ exports[`TextInput select suggestion 4`] = `
 </div>
 `;
 
+exports[`TextInput select with onSuggestionSelect when onSelect is present 1`] = `
+.c0 {
+  font-size: 18px;
+  line-height: 24px;
+  box-sizing: border-box;
+  -webkit-text-size-adjust: 100%;
+  -ms-text-size-adjust: 100%;
+  -moz-osx-font-smoothing: grayscale;
+  -webkit-font-smoothing: antialiased;
+}
+
+.c2 {
+  box-sizing: border-box;
+  font-size: inherit;
+  font-family: inherit;
+  border: none;
+  -webkit-appearance: none;
+  background: transparent;
+  color: inherit;
+  width: 100%;
+  padding: 11px;
+  font-weight: 600;
+  margin: 0;
+  border: 1px solid rgba(0,0,0,0.33);
+  border-radius: 4px;
+}
+
+.c2::-webkit-input-placeholder {
+  color: #AAAAAA;
+}
+
+.c2::-moz-placeholder {
+  color: #AAAAAA;
+}
+
+.c2:-ms-input-placeholder {
+  color: #AAAAAA;
+}
+
+.c2::-webkit-search-decoration {
+  -webkit-appearance: none;
+}
+
+.c2::-moz-focus-inner {
+  border: none;
+  outline: none;
+}
+
+.c2:-moz-placeholder,
+.c2::-moz-placeholder {
+  opacity: 1;
+}
+
+.c1 {
+  position: relative;
+  width: 100%;
+}
+
+<div
+  class="c0"
+>
+  <div
+    class="c1"
+  >
+    <input
+      autocomplete="off"
+      class="c2"
+      data-testid="test-input"
+      id="item"
+      name="item"
+      value=""
+    />
+  </div>
+</div>
+`;
+
 exports[`TextInput should not have accessibility violations 1`] = `
 .c0 {
   font-size: 18px;
diff --git a/src/js/components/__tests__/__snapshots__/README-test.js.snap b/src/js/components/__tests__/__snapshots__/README-test.js.snap
index 63f69f37004..ba44d0386d6 100644
--- a/src/js/components/__tests__/__snapshots__/README-test.js.snap
+++ b/src/js/components/__tests__/__snapshots__/README-test.js.snap
@@ -15416,8 +15416,20 @@ function
 
 **onSelect**
 
+Note: This function is deprecated, use onSuggestionSelect instead.
+      Function that will be called when the user selects a suggestion.
+      The suggestion contains the object chosen from the supplied suggestions.
+      When used in conjunction with onSuggestionSelect 
+      this will default to React's onSelect
+
+\`\`\`
+function
+\`\`\`
+
+**onSuggestionSelect**
+
 Function that will be called when the user selects a suggestion.
-The suggestion contains the object chosen from the supplied suggestions.
+      The suggestion contains the object chosen from the supplied suggestions.
 
 \`\`\`
 function
@@ -15450,7 +15462,7 @@ node
 **plain**
 
 Whether this is a plain input with no border or padding.
-Only use this when the containing context provides sufficient affordance
+      Only use this when the containing context provides sufficient affordance
 
 \`\`\`
 boolean
diff --git a/src/js/components/__tests__/__snapshots__/components-test.js.snap b/src/js/components/__tests__/__snapshots__/components-test.js.snap
index 5b9254e39ce..6a6ef6b714d 100644
--- a/src/js/components/__tests__/__snapshots__/components-test.js.snap
+++ b/src/js/components/__tests__/__snapshots__/components-test.js.snap
@@ -7189,11 +7189,20 @@ string",
         "name": "onChange",
       },
       Object {
-        "description": "Function that will be called when the user selects a suggestion.
-The suggestion contains the object chosen from the supplied suggestions.",
+        "description": "Note: This function is deprecated, use onSuggestionSelect instead.
+      Function that will be called when the user selects a suggestion.
+      The suggestion contains the object chosen from the supplied suggestions.
+      When used in conjunction with onSuggestionSelect 
+      this will default to React's onSelect",
         "format": "function",
         "name": "onSelect",
       },
+      Object {
+        "description": "Function that will be called when the user selects a suggestion.
+      The suggestion contains the object chosen from the supplied suggestions.",
+        "format": "function",
+        "name": "onSuggestionSelect",
+      },
       Object {
         "description": "Function that will be called when the suggestions drop is opened.",
         "format": "function",
@@ -7211,7 +7220,7 @@ The suggestion contains the object chosen from the supplied suggestions.",
       },
       Object {
         "description": "Whether this is a plain input with no border or padding.
-Only use this when the containing context provides sufficient affordance",
+      Only use this when the containing context provides sufficient affordance",
         "format": "boolean",
         "name": "plain",
       },