Repository: mlaursen/react-md
Pull request #856 (2019-12-19 16:20:29)

Added saveListScrollTop and listScrollTopUpdate props to SelectField
Hello,

I've added `saveListScrollTop` and `listScrollTopUpdate` props into `SelectField` to have ability to control active item's position inside dropdown list.

mlaursen: Sorry for the delay! I just pulled it down and it looks good to me. Thanks! I'll release this as v1.14.0

Patch:
diff --git a/docs/src/components/Components/SelectFields/Simple.jsx b/docs/src/components/Components/SelectFields/Simple.jsx
index 6dd45444cc..826431f94a 100644
--- a/docs/src/components/Components/SelectFields/Simple.jsx
+++ b/docs/src/components/Components/SelectFields/Simple.jsx
@@ -29,6 +29,10 @@ function getActiveLabel({ activeLabel, activeIndex }) {
   return activeIndex > -1 ? `#${activeIndex + 1}: ${activeLabel}` : activeLabel;
 }
 
+function listScrollTopUpdate({ activeItemNode }) {
+  return activeItemNode.offsetHeight;
+}
+
 const Simple = ({ simplifiedMenu }) => (
   <div className="md-grid">
     <h4 className="md-cell md-cell--12">Normal SelectFields</h4>
@@ -65,6 +69,24 @@ const Simple = ({ simplifiedMenu }) => (
       getActiveLabel={getActiveLabel}
       simplifiedMenu={simplifiedMenu}
     />
+    <SelectField
+      id="select-field-3-2"
+      label="Preserve List's Scroll Top Position"
+      placeholder="Placeholder"
+      className="md-cell"
+      menuItems={STRING_ITEMS}
+      saveListScrollTop
+      simplifiedMenu={simplifiedMenu}
+    />
+    <SelectField
+      id="select-field-3-3"
+      label="Change Active Item's Visible Position in List"
+      placeholder="Placeholder"
+      className="md-cell"
+      menuItems={STRING_ITEMS}
+      listScrollTopUpdate={listScrollTopUpdate}
+      simplifiedMenu={simplifiedMenu}
+    />
     <h4 className="md-cell md-cell--12">SelectField Buttons</h4>
     <SelectField
       id="select-field-4"
diff --git a/src/js/Lists/List.d.ts b/src/js/Lists/List.d.ts
index 5eec0d5b7a..2243a90e61 100644
--- a/src/js/Lists/List.d.ts
+++ b/src/js/Lists/List.d.ts
@@ -6,5 +6,8 @@ export interface ListProps extends Props {
   ordered?: boolean;
 }
 
-declare const List: React.ComponentClass<ListProps>;
+export interface ListComponent extends React.ComponentClass<ListProps> {
+}
+
+declare const List: ListComponent;
 export default List;
diff --git a/src/js/SelectFields/SelectField.d.ts b/src/js/SelectFields/SelectField.d.ts
index 25960c1a61..2512ba5045 100644
--- a/src/js/SelectFields/SelectField.d.ts
+++ b/src/js/SelectFields/SelectField.d.ts
@@ -1,5 +1,7 @@
 import * as React from 'react';
 import { IdPropType, Props } from '../index';
+import { ListComponent } from '../Lists/List';
+import { ListItemComponent } from '../Lists/ListItem';
 import { SharedTextFieldProps } from '../TextFields';
 
 import {
@@ -35,6 +37,18 @@ export interface GetActiveLabelParam {
   field: SelectFieldComponent;
 }
 
+export interface ListScrollTopUpdateParam {
+  listRef: ListComponent;
+  listNode: HTMLElement;
+  listScrollTop: number;
+  newListScrollTop: number;
+  listItems: ListItemComponent[];
+  activeItemRef: ListItemComponent;
+  activeItemNode: HTMLElement;
+  activeIndex: number;
+  field: SelectFieldComponent;
+}
+
 export interface FieldDataProps {
   id: string;
   name: string;
@@ -60,6 +74,8 @@ export interface SharedSelectFieldProps extends BaseMenuProps, SharedTextFieldPr
   name?: string;
   getItemProps?: (data: GetItemPropsParam) => Object;
   getActiveLabel?: (data: GetActiveLabelParam) => React.ReactNode;
+  saveListScrollTop?: boolean;
+  listScrollTopUpdate?: number | ((data: ListScrollTopUpdateParam) => number);
   defaultValue?: ListValue;
   value?: ListValue;
   onChange?: (value: ListValue, selectedIndex: number, event: React.MouseEvent<HTMLElement>, data: FieldDataProps) => void;
diff --git a/src/js/SelectFields/SelectField.js b/src/js/SelectFields/SelectField.js
index 8cc949dd34..279c0a4535 100644
--- a/src/js/SelectFields/SelectField.js
+++ b/src/js/SelectFields/SelectField.js
@@ -229,6 +229,36 @@ export default class SelectField extends PureComponent {
      */
     getActiveLabel: PropTypes.func,
 
+    /**
+     * Whether scroll top position of dropdown list should be preserved when the list is opened next time.
+     * By default the list is scrolled so that active item is displayed on top of visible area of the list.
+     * Has priority over `listScrollTopUpdate` prop.
+     */
+    saveListScrollTop: PropTypes.bool,
+
+    /**
+     * Specifies update for scroll top position of dropdown list when the list is opened.
+     * By default the list is scrolled so that active item is displayed on top of visible area of the list.
+     * When this property is set the property value or function's result is subtracted
+     * from calculated scroll top position of the list.
+     *
+     * An object with the following fields will be passed into the function:
+     * - `listRef` - reference to list
+     * - `listNode` - reference to list's DOM node
+     * - `listScrollTop` - current value of `scrollTop` property of list's DOM node
+     * - `newListScrollTop` - calculated value for `scrollTop` property of list's DOM node
+     *    (will be changed if the function returns non-zero value)
+     * - `listItems` - array of references to list items
+     * - `activeItemRef` - reference to active list item
+     * - `activeItemNode` - reference to active list item's DOM node
+     * - `activeIndex` - active item's index
+     * - `field` - reference to the component instance
+     */
+    listScrollTopUpdate: PropTypes.oneOfType([
+      PropTypes.number,
+      PropTypes.func,
+    ]),
+
     /**
      * The default value to use for the select field. If this is set, it should either match
      * one of the `number` or `string` in your `menuItems` list or be the empty string. If
@@ -588,6 +618,8 @@ export default class SelectField extends PureComponent {
     transitionTime: 300,
     repositionOnScroll: true,
     repositionOnResize: false,
+    saveListScrollTop: false,
+    listScrollTopUpdate: 0,
   };
 
   constructor(props) {
@@ -718,16 +750,39 @@ export default class SelectField extends PureComponent {
   };
 
   _scrollActiveIntoView = (listRef) => {
+    const activeItem = this._activeItem;
+    const { props } = this;
     if (listRef === null) {
       this._items = [];
       return;
-    } else if (!this._activeItem) {
+    } else if (!activeItem || props.saveListScrollTop) {
       return;
     }
 
     const list = findDOMNode(listRef);
-    const { offsetTop } = this._activeItem;
-    list.scrollTop = offsetTop > MOBILE_LIST_PADDING ? offsetTop : 0;
+    const { offsetTop } = activeItem;
+    let newListScrollTop = offsetTop > MOBILE_LIST_PADDING ? offsetTop : 0;
+
+    const { listScrollTopUpdate } = props;
+    if (listScrollTopUpdate) {
+      const { activeIndex } = this.state;
+      const listItems = this._items;
+      newListScrollTop -= typeof listScrollTopUpdate === 'function'
+        ? listScrollTopUpdate({
+          listRef,
+          listNode: list,
+          listScrollTop: list.scrollTop,
+          newListScrollTop,
+          listItems,
+          activeItemRef: listItems[activeIndex],
+          activeItemNode: activeItem,
+          activeIndex,
+          field: this,
+        })
+        : listScrollTopUpdate;
+    }
+
+    list.scrollTop = newListScrollTop;
   };
 
   _toggle = (e) => {
@@ -1065,6 +1120,8 @@ export default class SelectField extends PureComponent {
       itemProps,
       getItemProps,
       getActiveLabel,
+      saveListScrollTop,
+      listScrollTopUpdate,
       defaultValue,
       defaultVisible,
       onClick,


Test patch:
diff --git a/src/js/SelectFields/__tests__/SelectField.js b/src/js/SelectFields/__tests__/SelectField.js
index 060a18bf8f..966c22f932 100644
--- a/src/js/SelectFields/__tests__/SelectField.js
+++ b/src/js/SelectFields/__tests__/SelectField.js
@@ -290,4 +290,48 @@ describe('SelectField', () => {
     label = field.find(FloatingLabel);
     expect(label.props().floating).toBe(true);
   });
+
+  it('should call function to change list\'s scrollTop', () => {
+    const listScrollTopUpdate = jest.fn(() => 48);
+    const field = mount(
+      <SelectField
+        id="test"
+        menuItems={['a', 'b', 'c', 'd', 'e']}
+        value="c"
+        listScrollTopUpdate={listScrollTopUpdate}
+        onChange={jest.fn()}
+        onVisibilityChange={jest.fn()}
+      />
+    );
+
+    const calls = listScrollTopUpdate.mock.calls;
+    expect(calls.length).toBe(0);
+
+    field.setProps({ visible: true });
+    expect(calls.length).toBe(1);
+    expect(calls[0].length).toBe(1);
+    expect(typeof calls[0][0]).toBe('object');
+    expect(calls[0][0]).not.toBeNull();
+  });
+
+  it('should not call function to change list\'s scrollTop', () => {
+    const listScrollTopUpdate = jest.fn(() => 48);
+    const field = mount(
+      <SelectField
+        id="test"
+        menuItems={['a', 'b', 'c', 'd', 'e']}
+        value="c"
+        saveListScrollTop
+        listScrollTopUpdate={listScrollTopUpdate}
+        onChange={jest.fn()}
+        onVisibilityChange={jest.fn()}
+      />
+    );
+
+    const calls = listScrollTopUpdate.mock.calls;
+    expect(calls.length).toBe(0);
+
+    field.setProps({ visible: true });
+    expect(calls.length).toBe(0);
+  });
 });