Is getMenuProps necessary in React downshift autocomplete library?

89 views Asked by At

I see the readme on getMenuProps, but it doesn't explain what the refs are used for. It seems to have an effect when I move it around. I'm not sure if it is required to go on a list element directly above child item elements? Or can it go anywhere in the parent tree?

I have this basically for implementing an autocomplete component with downshift + react-virtualized:

import React, { useCallback, useMemo, useRef } from 'react'
import Downshift, { StateChangeOptions } from 'downshift'
import Input from './Input'
import { matchSorter } from 'match-sorter'
import { CloseIcon, IconButton, useSize } from './base'
import TriangleDownIcon from './icon/TriangleDown'
import List, {
  ListRowProps,
} from 'react-virtualized/dist/commonjs/List'

export type ItemViewCast = {
  isActive?: boolean
  isSelected?: boolean
  item: ItemCast
  list: Array<ItemCast>
}

export type ItemCast = {
  height?: number
  value: string
  search: string
}

export default function Autocomplete({
  value,
  onChange,
  placeholder,
  items,
  itemRenderer,
  listHeight,
  overscanRowCount = 10,
  useDynamicItemHeight,
  itemHeight = 128,
  showScrollingPlaceholder,
  clearable,
  id,
  renderItemToString,
}: {
  renderItemToString: (item: ItemCast | null) => string
  id?: string
  value?: string
  placeholder?: string
  onChange: (val?: string) => void
  items: Array<ItemCast>
  itemRenderer: React.ComponentType<ItemViewCast>
  listHeight?: number | string
  overscanRowCount?: number
  useDynamicItemHeight?: boolean
  itemHeight?: number
  showScrollingPlaceholder?: boolean
  clearable?: boolean
}) {
  const map = useMemo(() => {
    return items.reduce<Record<string, number>>((m, x, i) => {
      if (x.value) {
        m[x.value] = i
      }
      return m
    }, {})
  }, [items])

  const handleStateChange = (changes: StateChangeOptions<ItemCast>) => {
    if (changes.hasOwnProperty('selectedItem')) {
      onChange(changes.selectedItem?.value ?? undefined)
    } else if (changes.hasOwnProperty('inputValue')) {
      // onChange(changes.inputValue ?? undefined)
    }
  }

  const clearSelection = () => {
    if (clearable) {
      onChange(undefined)
    }
  }

  const getItemHeight = useCallback(
    ({ index }: { index: number }) => {
      return items[index]?.height ?? itemHeight
    },
    [items, itemHeight],
  )

  // const handleScrollToRowChange = (event) => {
  //   const {rowCount} = this.state;
  //   let scrollToIndex = Math.min(
  //     rowCount - 1,
  //     parseInt(event.target.value, 10),
  //   );

  //   if (isNaN(scrollToIndex)) {
  //     scrollToIndex = undefined;
  //   }

  //   // this.setState({scrollToIndex});
  // }

  const selectedIndex = value ? map[value] : undefined
  const selectedItem =
    selectedIndex != null ? items[selectedIndex] : undefined

  return (
    <Downshift
      id={id ? `autocomplete-${id}` : undefined}
      inputValue={value}
      onStateChange={handleStateChange}
      itemToString={renderItemToString}
    >
      {({
        getLabelProps,
        getInputProps,
        getToggleButtonProps,
        getMenuProps,
        getItemProps,
        getRootProps,
        isOpen,
        selectedItem,
        inputValue,
        highlightedIndex,
      }) => {
        return (
          <div className="w-full relative">
            <div className="w-full relative">
              <Input
                {...getInputProps({
                  isOpen,
                  // inputValue,
                  placeholder,
                })}
              />
              <div className="absolute right-0 top-0 h-full">
                <div className="flex items-center h-full">
                  {selectedItem && clearable ? (
                    <IconButton
                      onClick={clearSelection}
                      aria-label="clear selection"
                      className="w-32 h-32"
                    >
                      <CloseIcon />
                    </IconButton>
                  ) : (
                    <IconButton
                      className="w-32 h-32"
                      {...getToggleButtonProps()}
                    >
                      <TriangleDownIcon />
                    </IconButton>
                  )}
                </div>
              </div>
            </div>
            {isOpen && (
              <Menu
                id={id}
                itemRenderer={itemRenderer}
                overscanRowCount={overscanRowCount}
                listHeight={listHeight}
                getMenuProps={getMenuProps}
                items={items}
                inputValue={inputValue ?? undefined}
                value={value}
                highlightedIndex={highlightedIndex ?? undefined}
                useDynamicItemHeight={useDynamicItemHeight}
                getItemHeight={getItemHeight}
                itemHeight={itemHeight}
                showScrollingPlaceholder={showScrollingPlaceholder}
                getItemProps={getItemProps}
              />
            )}
          </div>
        )
      }}
    </Downshift>
  )
}

function Menu({
  getMenuProps,
  listHeight,
  overscanRowCount,
  items,
  inputValue,
  value,
  highlightedIndex,
  useDynamicItemHeight,
  getItemHeight,
  itemHeight,
  showScrollingPlaceholder,
  itemRenderer,
  getItemProps,
  id,
}: {
  id?: string
  items: Array<ItemCast>
  listHeight?: number | string
  value?: string
  overscanRowCount: number
  highlightedIndex?: number
  useDynamicItemHeight?: boolean
  itemHeight: number
  getItemHeight: ({ index }: { index: number }) => number
  inputValue?: string
  getMenuProps: () => Record<string, any>
  getItemProps: (opts: any) => Record<string, any>
  showScrollingPlaceholder?: boolean
  itemRenderer: React.ComponentType<ItemViewCast>
}) {
  const listRef = useRef(null)
  const filteredItems = useMemo(() => {
    if (!inputValue) {
      return items
    }
    return matchSorter(items, inputValue, { keys: ['search'] })
  }, [items, inputValue])
  const filteredMap = useMemo(() => {
    return filteredItems.reduce<Record<string, number>>((m, x, i) => {
      if (x.value) {
        m[x.value] = i
      }
      return m
    }, {})
  }, [filteredItems])

  const rowCount = filteredItems.length
  const selectedIndex = value ? filteredMap[value] : undefined

  const Item = itemRenderer
  // const selectedItem =
  //   selectedIndex != null ? filteredItems[selectedIndex] : undefined

  const rowRenderer = ({
    index,
    isScrolling,
    key,
    style,
    getItemProps,
    highlightedIndex,
  }: ListRowProps & {
    getItemProps: (opts: any) => Record<string, any>
    highlightedIndex?: number
  }) => {
    if (showScrollingPlaceholder && isScrolling) {
      return (
        <div
          // className={cx(styles.row, styles.isScrollingPlaceholder)}
          key={key}
          style={style}
        >
          Scrolling...
        </div>
      )
    }

    const item = filteredItems[index]

    if (useDynamicItemHeight) {
      // switch (item.size) {
      //   case 75:
      //     additionalContent = <div>It is medium-sized.</div>
      //     break
      //   case 100:
      //     additionalContent = (
      //       <div>
      //         It is large-sized.
      //         <br />
      //         It has a 3rd row.
      //       </div>
      //     )
      //     break
      // }
    }

    return (
      <Item
        key={item.value}
        item={item}
        list={filteredItems}
        {...getItemProps({
          item,
          index,
          isActive: highlightedIndex === index,
          isSelected: selectedIndex === index,
        })}
      />
    )
  }

  const ref = useRef(null)
  const size = useSize(ref)

  return (
    <div
      className="w-full relative"
      style={{ height: listHeight }}
      ref={ref}
    >
      <div className="w-full absolute h-full bg-zinc-50 z-3000">
        <List
          id={id ? `autocomplete-list-${id}` : undefined}
          className="relative w-full"
          ref={listRef}
          height={size?.height ?? 0}
          overscanRowCount={overscanRowCount}
          // noRowsRenderer={this._noRowsRenderer}
          rowCount={rowCount}
          rowHeight={useDynamicItemHeight ? getItemHeight : itemHeight}
          containerProps={getMenuProps()}
          rowRenderer={(props: ListRowProps) => {
            return rowRenderer({
              ...props,
              getItemProps,
              highlightedIndex: highlightedIndex ?? undefined,
            })
          }}
          // scrollToIndex={scrollToIndex}
          width={size?.width ?? 128}
        />
        {/* // </AutoSizer> */}
      </div>
    </div>
  )
}

The problem I think is that react-virtualized inside List has this DOM structure:

<div class="grid">
  <div class="scroller">
    <div class="item">item from `Item` component 1</div>
    <div class="item">item from `Item` component 2</div>
  </div>
</div>

(don't remember what the exact classes are, but that is the gist).

You can set <List containerProps={getMenuItems()}> but that sets it on the above <div class="grid"> element, not the scroller. There is no way to specify scroller props. Is there any way to get this working?

So far I have tried in these 3 places in the above Autocomplete code, and each causes different behavior, none of which are correct:

<div
  className="w-full relative"
  style={{ height: listHeight }}
  ref={ref}
  {...getMenuProps()}
>
  <div  {...getMenuProps()} className="w-full absolute h-full bg-zinc-50 z-3000">
    <List containerProps={getMenuProps()}>

Leaving getMenuProps off altogether seems to be working the best so far, but not 100% sure. So I'm wondering in part if it's necessary to use. Or how can I get this working with react-virtualized?

With getMenuProps off altogether

Actually it doesn't seem to be working, scrolling is messed up, not sure which lib that is.

enter image description here

With <List containerProps={getMenuProps()}>

It shows extra space at the end of the scroller it looks like, which is also wrong.

enter image description here

With <div {...getMenuProps()}> on the absolute node

Scrolling is all jolty and things are overlapping.

enter image description here

1

There are 1 answers

0
Lance On

I got it all mostly working now:

  • scrolling works
  • going down to zero items works
  • it is much better performance than rendering a huge list in a select box
  • clicking an item works
  • keyboard enter works

I did two main things:

  1. Forked react-virtualized to add a simple scrollerProps property to the List so it can be given the getMenuProps() directly above the children. Not sure if that totally was necessary yet.
  2. Implemented stateReducer on the Downshift element, it seems the default stateReducer is not what you'd want, so I made it do what I want.

Here is the code:

import React, {
  CSSProperties,
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from 'react'
import Downshift, { StateChangeOptions } from 'downshift'
import Input from './Input'
import { matchSorter } from 'match-sorter'
import { CloseIcon, IconButton, useSize } from './base'
import TriangleDownIcon from './icon/TriangleDown'
import List, {
  ListRowProps,
} from '@lancejpollard/react-virtualized/dist/commonjs/List'

export type ItemViewCast = {
  isActive?: boolean
  isSelected?: boolean
  item: ItemCast
  list: Array<ItemCast>
  style?: CSSProperties
}

export type ItemCast = {
  height?: number
  value: string
  search: string
}

export default function Autocomplete({
  value,
  onChange,
  placeholder,
  items,
  itemRenderer,
  listHeight,
  overscanRowCount = 10,
  useDynamicItemHeight,
  itemHeight = 128,
  showScrollingPlaceholder,
  clearable,
  id,
  renderItemToString,
}: {
  renderItemToString: (item: ItemCast | null) => string
  id?: string
  value?: string
  placeholder?: string
  onChange: (val?: string) => void
  items: Array<ItemCast>
  itemRenderer: React.ComponentType<ItemViewCast>
  listHeight?: number | string
  overscanRowCount?: number
  useDynamicItemHeight?: boolean
  itemHeight?: number
  showScrollingPlaceholder?: boolean
  clearable?: boolean
}) {
  const map = useMemo(() => {
    return items.reduce<Record<string, number>>((m, x, i) => {
      if (x.value) {
        m[x.value] = i
      }
      return m
    }, {})
  }, [items])
  const mapByLabel = useMemo(() => {
    return items.reduce<Record<string, number>>((m, x, i) => {
      if (x.search) {
        m[x.search] = i
      }
      return m
    }, {})
  }, [items])

  const selectedIndex = value ? map[value] : undefined
  const selectedItem =
    selectedIndex != null ? items[selectedIndex] : undefined
  const [input, setInput] = useState(
    selectedItem ? renderItemToString(selectedItem) : value,
  )

  useEffect(() => {
    setInput(selectedItem ? renderItemToString(selectedItem) : value)
  }, [value, selectedItem])

  const handleStateChange = (changes: StateChangeOptions<ItemCast>) => {
    // console.log('changes', changes)
    if (changes.hasOwnProperty('selectedItem')) {
      const selected = changes.selectedItem
      if (selected) {
        onChange(selected.value ?? undefined)
      } else {
        onChange(undefined)
      }
    } else if (changes.hasOwnProperty('inputValue')) {
      setInput(changes.inputValue ?? undefined)
    } else if (changes.type === Downshift.stateChangeTypes.blurInput) {
      setInput(selectedItem ? renderItemToString(selectedItem) : value)
    }
  }

  const clearSelection = () => {
    if (clearable) {
      onChange(undefined)
    }
  }

  const getItemHeight = useCallback(
    ({ index }: { index: number }) => {
      return items[index]?.height ?? itemHeight
    },
    [items, itemHeight],
  )

  // const handleScrollToRowChange = (event) => {
  //   const {rowCount} = this.state;
  //   let scrollToIndex = Math.min(
  //     rowCount - 1,
  //     parseInt(event.target.value, 10),
  //   );

  //   if (isNaN(scrollToIndex)) {
  //     scrollToIndex = undefined;
  //   }

  //   // this.setState({scrollToIndex});
  // }

  const stateReducer = (state: any, actionAndChanges: any) => {
    const { type, ...changes } = actionAndChanges
    // console.log('CHANGES', changes, type)
    switch (type) {
      case Downshift.stateChangeTypes.keyDownEnter:
      case Downshift.stateChangeTypes.clickItem:
        return {
          ...changes,
          isOpen: false, // keep menu open after selection.
          type,
          // highlightedIndex: state.highlightedIndex,
          // inputValue: '', // don't add the item string as input value at selection.
        }
      case Downshift.stateChangeTypes.blurInput:
        return {
          ...changes,
          // inputValue: '', // don't add the item string as input value at selection.
          type,
        }
      default:
        return { ...changes, type }
    }
  }

  return (
    <Downshift
      id={id ? `autocomplete-${id}` : undefined}
      inputValue={input}
      stateReducer={stateReducer}
      onStateChange={handleStateChange}
      onUserAction={handleStateChange}
      itemToString={renderItemToString}
    >
      {({
        getLabelProps,
        getInputProps,
        getToggleButtonProps,
        getMenuProps,
        getItemProps,
        getRootProps,
        isOpen,
        selectedItem,
        inputValue,
        highlightedIndex,
      }) => {
        return (
          <div className="w-full relative">
            <div className="w-full relative">
              <Input
                {...getInputProps({
                  isOpen,
                  placeholder,
                })}
              />
              <div className="absolute right-0 top-0 h-full">
                <div className="flex items-center h-full">
                  {selectedItem && clearable ? (
                    <IconButton
                      onClick={clearSelection}
                      aria-label="clear selection"
                      className="w-32 h-32"
                    >
                      <CloseIcon />
                    </IconButton>
                  ) : (
                    <IconButton
                      className="w-32 h-32"
                      {...getToggleButtonProps()}
                    >
                      <TriangleDownIcon />
                    </IconButton>
                  )}
                </div>
              </div>
            </div>
            {isOpen && (
              <Menu
                id={id}
                itemRenderer={itemRenderer}
                overscanRowCount={overscanRowCount}
                listHeight={listHeight}
                getMenuProps={getMenuProps}
                items={items}
                inputValue={inputValue ?? undefined}
                value={value}
                highlightedIndex={highlightedIndex ?? undefined}
                useDynamicItemHeight={useDynamicItemHeight}
                getItemHeight={getItemHeight}
                itemHeight={itemHeight}
                showScrollingPlaceholder={showScrollingPlaceholder}
                getItemProps={getItemProps}
              />
            )}
          </div>
        )
      }}
    </Downshift>
  )
}

function Menu({
  getMenuProps,
  listHeight,
  overscanRowCount,
  items,
  inputValue,
  value,
  highlightedIndex,
  useDynamicItemHeight,
  getItemHeight,
  itemHeight,
  showScrollingPlaceholder,
  itemRenderer,
  getItemProps,
  id,
}: {
  id?: string
  items: Array<ItemCast>
  listHeight?: number | string
  value?: string
  overscanRowCount: number
  highlightedIndex?: number
  useDynamicItemHeight?: boolean
  itemHeight: number
  getItemHeight: ({ index }: { index: number }) => number
  inputValue?: string
  getMenuProps: () => Record<string, any>
  getItemProps: (opts: any) => Record<string, any>
  showScrollingPlaceholder?: boolean
  itemRenderer: React.ComponentType<ItemViewCast>
}) {
  const listRef = useRef(null)
  const filteredItems = useMemo(() => {
    if (!inputValue) {
      return items
    }
    return matchSorter(items, inputValue, { keys: ['search'] })
  }, [items, inputValue])
  const filteredMap = useMemo(() => {
    return filteredItems.reduce<Record<string, number>>((m, x, i) => {
      if (x.value) {
        m[x.value] = i
      }
      return m
    }, {})
  }, [filteredItems])

  const rowCount = filteredItems.length
  const selectedIndex = value ? filteredMap[value] : undefined

  const Item = itemRenderer
  // const selectedItem =
  //   selectedIndex != null ? filteredItems[selectedIndex] : undefined

  const rowRenderer = ({
    index,
    isScrolling,
    key,
    style,
    getItemProps,
    highlightedIndex,
  }: any & {
    getItemProps: (opts: any) => Record<string, any>
    highlightedIndex?: number
  }) => {
    if (showScrollingPlaceholder && isScrolling) {
      return (
        <div
          // className={cx(styles.row, styles.isScrollingPlaceholder)}
          key={key}
          style={style}
        >
          Scrolling...
        </div>
      )
    }

    const item = filteredItems[index]

    if (useDynamicItemHeight) {
      // switch (item.size) {
      //   case 75:
      //     additionalContent = <div>It is medium-sized.</div>
      //     break
      //   case 100:
      //     additionalContent = (
      //       <div>
      //         It is large-sized.
      //         <br />
      //         It has a 3rd row.
      //       </div>
      //     )
      //     break
      // }
    }

    return (
      <Item
        key={item.value}
        item={item}
        list={filteredItems}
        style={style}
        {...getItemProps({
          item,
          index,
          isActive: highlightedIndex === index,
          isSelected: selectedIndex === index,
        })}
      />
    )
  }

  const ref = useRef(null)
  const size = useSize(ref)
  const totalRowHeight = useDynamicItemHeight
    ? undefined
    : rowCount * itemHeight
  const measuredHeight = window.innerHeight / 2 //size?.height ?? 0
  const actualListHeight =
    totalRowHeight && totalRowHeight > measuredHeight
      ? measuredHeight
      : totalRowHeight
      ? totalRowHeight
      : rowCount
      ? measuredHeight
      : 0

  return (
    <div
      className="w-full relative"
      style={{ height: actualListHeight }}
      ref={ref}
    >
      <div className="w-full absolute h-full bg-zinc-50 z-3000">
        <List
          id={id ? `autocomplete-list-${id}` : undefined}
          className="relative w-full"
          ref={listRef}
          height={actualListHeight}
          overscanRowCount={overscanRowCount}
          // noRowsRenderer={this._noRowsRenderer}
          rowCount={rowCount}
          rowHeight={useDynamicItemHeight ? getItemHeight : itemHeight}
          scrollerProps={actualListHeight ? getMenuProps() : {}}
          rowRenderer={(props: any) => {
            return rowRenderer({
              ...props,
              getItemProps,
              highlightedIndex: highlightedIndex ?? undefined,
            })
          }}
          // scrollToIndex={scrollToIndex}
          width={size?.width ?? 128}
        />
        {/* // </AutoSizer> */}
      </div>
    </div>
  )
}