Browse Source

fix(autocomplete): Only scroll highlighted index into view when using keyboard [UP-139] (#38809)

AutoComplete was scrolling items into view when moused over, which was
causing some erratic behavior. Items will now only be scrolled into view when using the keyboard to
navigate up and down.

This uses the same approach that Downshift does - generating unique IDs
and calling document.getElementById() to avoid having to maintain a
bunch of refs:
https://github.com/downshift-js/downshift/blob/a6bcd2247f4757b9a6c4077fdd9cf07554049054/src/downshift.js#L225
Malachi Willey 2 years ago
parent
commit
457c76fde5

+ 14 - 0
static/app/components/autoComplete.spec.jsx

@@ -502,6 +502,20 @@ describe('AutoComplete', function () {
       expect(input).toHaveValue('Pineapple');
     });
 
+    it('only scrolls highlighted item into view on keyboard events', function () {
+      const scrollIntoViewMock = jest.fn();
+      Element.prototype.scrollIntoView = scrollIntoViewMock;
+
+      createWrapper({isOpen: true});
+      expect(screen.getByTestId('test-autocomplete')).toBeInTheDocument();
+
+      fireEvent.mouseEnter(screen.getByText('Pineapple'));
+      expect(scrollIntoViewMock).not.toHaveBeenCalled();
+
+      fireEvent.keyDown(input, {key: 'ArrowDown', charCode: 40});
+      expect(scrollIntoViewMock).toHaveBeenCalledTimes(1);
+    });
+
     it('can reset input value when menu closes', function () {
       const wrapper = createWrapper({isOpen: true});
       jest.useFakeTimers();

+ 23 - 2
static/app/components/autoComplete.tsx

@@ -14,6 +14,7 @@ import DeprecatedDropdownMenu, {
   GetActorArgs,
   GetMenuArgs,
 } from 'sentry/components/deprecatedDropdownMenu';
+import {uniqueId} from 'sentry/utils/guid';
 
 const defaultProps = {
   itemToString: () => '',
@@ -170,6 +171,7 @@ class AutoComplete<T extends Item> extends Component<Props<T>, State<T>> {
   }
 
   private _mounted: boolean = false;
+  private _id = `autocomplete-${uniqueId()}`;
 
   /**
    * Used to track keyboard navigation of items.
@@ -201,6 +203,17 @@ class AutoComplete<T extends Item> extends Component<Props<T>, State<T>> {
     return this.isOpenIsControlled ? this.props.isOpen : this.state.isOpen;
   }
 
+  makeItemId = (index: number) => {
+    return `${this._id}-item-${index}`;
+  };
+
+  getItemElement = (index: number) => {
+    const id = this.makeItemId(index);
+    const element = document.getElementById(id);
+
+    return element;
+  };
+
   /**
    * Resets `this.items` and `this.state.highlightedIndex`.
    * Should be called whenever `inputValue` changes.
@@ -381,7 +394,14 @@ class AutoComplete<T extends Item> extends Component<Props<T>, State<T>> {
     // Make sure new index is within bounds
     newIndex = Math.max(0, Math.min(newIndex, listSize - 1));
 
-    this.setState({highlightedIndex: newIndex});
+    this.setState({highlightedIndex: newIndex}, () => {
+      // Scroll the newly highlighted element into view
+      const highlightedElement = this.getItemElement(newIndex);
+
+      if (highlightedElement && typeof highlightedElement.scrollIntoView === 'function') {
+        highlightedElement.scrollIntoView({block: 'nearest'});
+      }
+    });
   }
 
   /**
@@ -439,10 +459,11 @@ class AutoComplete<T extends Item> extends Component<Props<T>, State<T>> {
   }
 
   getItemProps = (itemProps: GetItemArgs<T>) => {
-    const {item, index: _index, ...props} = itemProps ?? {};
+    const {item, index, ...props} = itemProps ?? {};
 
     return {
       ...props,
+      id: this.makeItemId(index),
       role: 'option',
       'data-test-id': item['data-test-id'],
       onClick: this.makeHandleItemClick(itemProps),

+ 0 - 5
static/app/components/dropdownAutoComplete/row.tsx

@@ -28,10 +28,6 @@ type Props<T> = Pick<
     style?: React.CSSProperties;
   };
 
-function scrollIntoView(element: HTMLDivElement) {
-  element?.scrollIntoView?.({block: 'nearest'});
-}
-
 function Row<T extends Item>({
   item,
   style,
@@ -64,7 +60,6 @@ function Row<T extends Item>({
       disabled={item.disabled}
       isHighlighted={isHighlighted}
       style={style}
-      ref={isHighlighted ? scrollIntoView : undefined}
       {...itemProps}
     >
       {typeof item.label === 'function' ? item.label({inputValue}) : item.label}