如何为MUI AutoComplete组件添加无限滚动功能?

问题描述 投票:0回答:2

我有一个具有分页功能的 API 端点。我需要实现一个 AutoComplete 组件(使用 MUI5 AutoComplete 组件),每当我到达列表末尾时,它都会获取下一页的数据并将结果与上一个结果连接起来。

我在网上搜索过,但没有找到任何有用的结果。

我发现this类似的问题在两年多前就被问过,但直到今天还没有得到解答。

与我最接近的问题是这个,它也不够完整,我无法找到使用它的方法。

我真的很感谢您提供的任何帮助。

reactjs material-ui autocomplete infinite-scroll
2个回答
2
投票

尝试与 react-infinite-scroll-hook 结合

类似这样的:

const [sentryRef, { rootRef }] = useInfiniteScroll({
  loading: isLoading,
  hasNextPage,
  disabled: !open,
  onLoadMore: () => debouncedOptionsLoad(),
});
...
const renderSentry = () => {
  return hasNextPage || isLoading ? (
    <ListItem ref={sentryRef} key="sentry">
      Loading...
    </ListItem>
  ) : null;
};
...
const handleRenderOption = (optionProps, option) => {
  const { id, label } = option;

  if (id === "sentry") {
    return renderSentry();
  }

  return (
    <ListItem {...optionProps} key={id}>
      //for highlighting input string
      <Hightlight value={label} filter={searchString} />
    </ListItem>
  );
};
...
const sentryOption = { id: "sentry", label: '' };
...
return (
  <MuiAutocomplete 
    ...some props
    //rootRef from useInfiniteScroll hook
    ListboxProps={{ ref: rootRef }},
    filterOptions={(filterOptions) => {
      //filterOptions need for adding sentry to your options
      const shouldRenderSentry = meta.hasNextPage;

      if (shouldRenderSentry) {
        filterOptions.push(sentryOption);
      }

      return filterOptions;
    
    }},
    renderInput={(inputProps) => (
      <TextField
        value={searchString}
        onChange={ //function for change "searchString" and refetch your data }
      />
    )}
    ...
  />

0
投票

所以,我正在使用我自己提供的组件。它可能有一些问题,但基本功能绝对有效。当用户滚动到下拉列表的底部时,会出现一个加载器,并从后端获取下一页选项。我将 Redux 与 RTK 查询结合使用,因此新的选项页面将附加到减速器中的现有选项中,如下所示:

builder.addMatcher(inventoryService.endpoints.searchSuppliers.matchFulfilled, (state, action) => {
      if (['success', 'info'].includes(action.payload?.type as ResponseType) && action.payload?.data?.suppliers) {
        if (action.payload?.data?.currentPage > 1) {
          const { suppliers, ...rest } = action.payload.data
          state.searchSuppliers = {
            suppliers: [...state.searchSuppliers.suppliers, ...suppliers],
            ...rest
          }
        } else state.searchSuppliers = action.payload.data
      }
    })

代码位于 Typescript 中,但我通常不会经常定义类型,因为我基本上使用 Javascript,所以请注意您在代码中看到的任何类型。如果需要,您可以定义类型。这是组件:

/* eslint-disable no-unused-vars */
import React, { useCallback, useMemo, useRef, useState } from 'react'
import { UseFormSetValue } from 'react-hook-form'
import { Autocomplete, Box, CircularProgress, Paper, Typography } from '@mui/material'
import { IconCheck, IconChevronDown, IconPlus } from '@tabler/icons-react'
import { debounce, unionBy } from 'lodash'

import { AvatarWithLabel } from '../AvatarWithLabel'
import { CustomErrors } from '../CustomErrors'
import { CustomOutlinedInput } from '../CustomOutlinedInput'

export interface AvatarSearchDropdownProps {
  heading?: React.ReactNode
  headingClass?: string
  gap?: string | number
  width?: string | number
  height?: string | number
  color?: string
  fontSize?: string | number
  lineHeight?: string | number
  fontWeight?: number | string
  padding?: string | number
  borderRadius?: string | number
  value?: any
  valueKey: string
  isRequired?: boolean
  placeholder?: string
  name: string
  error?: string
  totalPages: number
  options: any[]
  menuHeader?: string
  resourceName?: string
  onNewItemClick?: any
  isOptionEqualToValue: any
  setValue?: UseFormSetValue<any>
  onChange?: (value: any) => void
  getAvatarAttributes?: any
  getData: (search: string, page: number) => void
  getOptionLabel: any
  isLoadingOptions?: boolean
}

const DropdownItem = ({
  index,
  totalOptions,
  fontSize,
  fontWeight,
  lineHeight,
  getAvatarAttributes,
  getOptionLabel,
  option,
  selected,
  lastOptionRef
}: {
  index: number
  totalOptions: number
  fontSize?: string | number
  lineHeight?: string | number
  fontWeight?: number | string
  getAvatarAttributes?: any
  getOptionLabel: any
  option: any
  selected: boolean
  lastOptionRef: (node: any) => void
}) => (
  <Typography
    ref={index === totalOptions - 1 ? lastOptionRef : null}
    width="100%"
    display="flex"
    justifyContent="space-between"
    alignItems="center"
    gap="4px"
    fontSize={fontSize}
    lineHeight={lineHeight}
    fontWeight={fontWeight}
    color={selected ? '#1F69FF' : '#353A46'}>
    {getAvatarAttributes ? (
      <AvatarWithLabel
        label={getAvatarAttributes(option)?.label}
        subLabel={getAvatarAttributes(option)?.subLabel}
        imgSrc={getAvatarAttributes(option)?.imgSrc}
      />
    ) : (
      getOptionLabel(option)
    )}

    {selected && <IconCheck color="#1F69FF" size={lineHeight} />}
  </Typography>
)

export const AvatarSearchDropdown = (props: AvatarSearchDropdownProps) => {
  const {
    heading,
    headingClass,
    gap = '8px',
    width = '100%',
    height = '48px',
    color = '#353A46',
    fontSize = '16px',
    lineHeight = '24px',
    fontWeight = 400,
    padding = '12px 12px',
    borderRadius = '8px',
    value,
    valueKey,
    isRequired,
    totalPages,
    placeholder,
    menuHeader,
    resourceName,
    onNewItemClick,
    name,
    error,
    options,
    isOptionEqualToValue,
    setValue,
    onChange,
    getAvatarAttributes,
    getOptionLabel,
    getData,
    isLoadingOptions
  } = props

  const [searchTerm, setSearchTerm] = useState('')
  const [page, setPage] = useState(1)

  const observer = useRef<IntersectionObserver | null>(null)

  const handleSearchChange = useCallback(
    debounce((newSearchTerm: string, getData: any) => {
      setPage(1)
      getData(newSearchTerm, 1)
    }, 500),
    []
  )

  const handleInputChange = (event: any) => {
    const newSearchTerm = event?.target?.value || ''
    setSearchTerm(newSearchTerm)
    handleSearchChange(newSearchTerm, getData)
  }

  const handleSelect = (_: any, option: any) => {
    onChange
      ? onChange(option)
      : setValue && setValue(name, option, { shouldDirty: true, shouldValidate: true, shouldTouch: true })
  }

  const updatedOptions = useMemo(() => {
    return value ? unionBy(options ?? [], [value], valueKey) : (options ?? [])
  }, [options, value, valueKey])

  const lastOptionRef = useCallback(
    (node: any) => {
      if (isLoadingOptions) return
      if (observer.current) observer.current.disconnect()
      observer.current = new IntersectionObserver((entries) => {
        if (entries[0].isIntersecting && page < totalPages) {
          getData(searchTerm, page + 1)
          setPage(page + 1)
        }
      })
      if (node) observer.current.observe(node)
    },
    [isLoadingOptions, page, totalPages, getData, searchTerm]
  )

  const CustomPaper = useMemo(() => {
    const Component = (props: any) => {
      return (
        <Paper
          {...props}
          sx={{
            borderRadius: '8px',
            '& .MuiAutocomplete-option': {
              margin: '8px 4px',
              borderRadius: '8px'
            },
            '& .Mui-focused .MuiTypography-root': {
              color: '#1F69FF'
            }
          }}
          className="thin-scrollbar-class">
          <Box>
            {menuHeader && (
              <Typography p="16px 16px 8px 16px" fontSize={fontSize} lineHeight={lineHeight} fontWeight={fontWeight}>
                {menuHeader}
              </Typography>
            )}

            {props.children}

            {resourceName && onNewItemClick && (
              <Typography
                onMouseDown={onNewItemClick}
                display="flex"
                p="12px"
                className="cursor-pointer border-t border-[#D1D4DC] hover:bg-slate-100"
                fontSize={fontSize}
                lineHeight={lineHeight}
                fontWeight={fontWeight}
                color="#1F69FF">
                <IconPlus /> Add New {resourceName}
              </Typography>
            )}
          </Box>
        </Paper>
      )
    }

    // Set the display name
    Component.displayName = 'CustomPaper'

    return Component
  }, [menuHeader, fontSize, lineHeight, fontWeight, resourceName, onNewItemClick])

  return (
    <Box display="flex" flexDirection="column" gap={gap} width={width}>
      {heading && (
        <Typography variant="body2" className={headingClass}>
          {heading} {isRequired && <span className="text-[#7AA7FF]">*</span>}
        </Typography>
      )}

      <Autocomplete
        value={value}
        fullWidth
        options={updatedOptions}
        getOptionLabel={getOptionLabel}
        onChange={handleSelect}
        onInputChange={handleInputChange}
        noOptionsText={isLoadingOptions ? <CircularProgress size={lineHeight} /> : 'No results found'}
        isOptionEqualToValue={isOptionEqualToValue}
        renderOption={(props, option, { selected, index }) => {
          const { key, ...optionProps } = props

          return (
            <li className="!px-0 !py-0 !mx-0" key={key} {...optionProps}>
              {isLoadingOptions && index === updatedOptions.length - 1 ? (
                <Box className="w-full flex gap-2 flex-col items-center">
                  <DropdownItem
                    index={index}
                    totalOptions={updatedOptions.length}
                    fontSize={fontSize}
                    fontWeight={fontWeight}
                    lineHeight={lineHeight}
                    lastOptionRef={lastOptionRef}
                    getOptionLabel={getOptionLabel}
                    getAvatarAttributes={getAvatarAttributes}
                    option={option}
                    selected={selected}
                  />

                  <CircularProgress size={lineHeight} />
                </Box>
              ) : (
                <DropdownItem
                  index={index}
                  totalOptions={updatedOptions.length}
                  fontSize={fontSize}
                  fontWeight={fontWeight}
                  lineHeight={lineHeight}
                  lastOptionRef={lastOptionRef}
                  getOptionLabel={getOptionLabel}
                  getAvatarAttributes={getAvatarAttributes}
                  option={option}
                  selected={selected}
                />
              )}
            </li>
          )
        }}
        renderInput={(params) => (
          <CustomOutlinedInput
            {...params}
            height={height}
            fontSize={fontSize}
            lineHeight={lineHeight}
            placeholder={placeholder}
            padding={padding}
            borderRadius={borderRadius}
            color={color}
          />
        )}
        PaperComponent={CustomPaper}
        popupIcon={<IconChevronDown />}
      />

      <CustomErrors margin="0px 12px" errors={error} />
    </Box>
  )
}

如有任何疑问,请随时联系。

© www.soinside.com 2019 - 2024. All rights reserved.