Last active
March 23, 2024 10:13
-
-
Save sibelius/9b89371f2b846dcdb8bed3165ac372c3 to your computer and use it in GitHub Desktop.
@material-ui Autocomplete lab with react-window + infinite-loader for GraphQL/Relay connections
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { useRef, useState } from 'react'; | |
import { Typography } from '@material-ui/core'; | |
import TextField from '@material-ui/core/TextField'; | |
import CircularProgress from '@material-ui/core/CircularProgress'; | |
import Autocomplete, { | |
AutocompleteChangeDetails, | |
AutocompleteChangeReason, | |
AutocompleteProps | |
} from '@material-ui/lab/Autocomplete'; | |
import { makeStyles } from '@material-ui/core/styles'; | |
import { Disposable } from 'relay-runtime'; | |
import { useTranslation } from 'react-i18next'; | |
import { useDebouncedCallback } from 'use-debounce'; | |
import { RelayRefetchProp } from 'react-relay'; | |
type PageInfo = { | |
hasNextPage: boolean; | |
startCursor: string | undefined; | |
endCursor: string | undefined; | |
}; | |
type Edge<T> = { | |
cursor: string; | |
node: T; | |
}; | |
export type Connection<T> = { | |
count: number; | |
totalCount: number; | |
endCursorOffset: number; | |
startCursorOffset: number; | |
pageInfo: PageInfo; | |
edges: Edge<T>[]; | |
}; | |
import ListboxComponent from './Listbox'; | |
const DEBOUNCE_DELAY = 500; | |
const TOTAL_REFETCH_ITEMS = 10; | |
const useStyles = makeStyles({ | |
listbox: { | |
'& ul': { | |
padding: 0, | |
margin: 0, | |
}, | |
}, | |
}); | |
type Props<Optino> = { | |
connection: Connection<Optino>; | |
filters: object; | |
label: string; | |
relay: RelayRefetchProp; | |
} & AutocompleteProps<Optino>; | |
const AutocompleteRelay = <Option extends object>(props: Props<Option>) => { | |
const { label, connection, filters = {}, relay, ...other } = props; | |
const classes = useStyles(); | |
const { t } = useTranslation(); | |
const [isLoading, setIsLoading] = useState<boolean>(false); | |
const [isLoadingMore, setIsLoadingMore] = useState<boolean>(false); | |
const [inputValue, setInputValue] = useState<string>(''); | |
const loadMoreDisposable = useRef<Disposable | null>(); | |
const newSearchDisposable = useRef<Disposable | null>(); | |
const { edges, pageInfo, count } = connection; | |
const options = edges.filter(({ node }) => !!node).map(({ node }) => node); | |
const getRefetchVariables = (search?: string) => (fragmentVariables) => { | |
return { | |
...fragmentVariables, | |
filters: { | |
...filters, | |
search: search ?? inputValue, | |
}, | |
}; | |
}; | |
const getRenderVariables = () => { | |
return { | |
filters: { | |
...filters, | |
search: inputValue, | |
}, | |
}; | |
}; | |
const [onInputChange] = useDebouncedCallback( | |
(event: React.ChangeEvent<{}>, value: string, reason: AutocompleteInputChangeReason) => { | |
// TODO - improve onInputChange reason changes | |
switch (reason) { | |
case 'clear': { | |
break; | |
} | |
case 'reset': { | |
break; | |
} | |
case 'input': { | |
break; | |
} | |
} | |
if (newSearchDisposable.current) { | |
newSearchDisposable.current.dispose(); | |
newSearchDisposable.current = null; | |
} | |
if (loadMoreDisposable.current) { | |
loadMoreDisposable.current.dispose(); | |
loadMoreDisposable.current = null; | |
} | |
setInputValue(value); | |
setIsLoading(true); | |
// TODO - timeout | |
newSearchDisposable.current = relay.refetch( | |
getRefetchVariables(value), | |
getRenderVariables, | |
() => { | |
setIsLoading(false); | |
setIsLoadingMore(false); | |
newSearchDisposable.current = null; | |
}, | |
{ force: true }, | |
); | |
}, | |
DEBOUNCE_DELAY, | |
); | |
const onChange = ( | |
event: React.ChangeEvent<{}>, | |
value: Option[], | |
reason: AutocompleteChangeReason, | |
details?: AutocompleteChangeDetails<Option>, | |
) => { | |
// TODO - improve onChange handling | |
// eslint-disable-next-line | |
console.log('onChange: ', event, value, reason, details); | |
}; | |
const isItemLoaded = (index: number): boolean => { | |
if (!pageInfo.hasNextPage) { | |
return true; | |
} | |
return !!edges[index]; | |
}; | |
const handleLoadMore = () => { | |
if (newSearchDisposable.current) { | |
// eslint-disable-next-line | |
console.log('new search in flight do not load more yet'); | |
return; | |
} | |
if (loadMoreDisposable.current) { | |
// eslint-disable-next-line | |
console.log('loadMore in flight do not load more yet'); | |
return; | |
} | |
if (!pageInfo.hasNextPage) { | |
// eslint-disable-next-line | |
console.log('loadMore hasNextPage false'); | |
return; | |
} | |
setIsLoadingMore(true); | |
const total = edges.length + TOTAL_REFETCH_ITEMS; | |
const refetchVariables = (fragmentVariables) => ({ | |
...getRefetchVariables()(fragmentVariables), | |
first: TOTAL_REFETCH_ITEMS, | |
after: pageInfo.endCursor, | |
}); | |
const renderVariables = { | |
first: total, | |
...getRenderVariables(), | |
}; | |
loadMoreDisposable.current = relay.refetch( | |
refetchVariables, | |
renderVariables, | |
() => { | |
setIsLoadingMore(false); | |
loadMoreDisposable.current = null; | |
}, | |
{ force: true }, | |
); | |
}; | |
// eslint-disable-next-line | |
const loadMoreItems = (startIndex: number, stopIndex: number) => { | |
if (!pageInfo.hasNextPage) { | |
return; | |
} | |
handleLoadMore(); | |
}; | |
const getItemCount = () => { | |
if (count) { | |
return count; | |
} | |
if (!pageInfo.hasNextPage) { | |
return edges.length; | |
} | |
return edges.length + 1; | |
}; | |
const itemCount = getItemCount(); | |
const ListboxProps = { | |
isItemLoaded, | |
loadMoreItems, | |
itemCount, | |
isLoadingMore, | |
}; | |
const loading = isLoading || isLoadingMore; | |
return ( | |
<Autocomplete<T> | |
style={{ width: 300 }} | |
disableListWrap | |
classes={classes} | |
ListboxComponent={ListboxComponent} | |
ListboxProps={ListboxProps} | |
options={options} | |
getOptionLabel={(option) => option.name} | |
getOptionSelected={(option, value) => option?.id === value?.id} | |
renderOption={(option) => <Typography noWrap>{option.name}</Typography>} | |
openOnFocus={true} | |
blurOnSelect={true} | |
fullWidth={true} | |
loading={loading} | |
loadingText={t('Loading...')} | |
noOptionsText={t('No items found')} | |
onInputChange={onInputChange} | |
onChange={onChange} | |
renderInput={(params) => ( | |
<TextField | |
{...params} | |
label={label} | |
InputProps={{ | |
...params.InputProps, | |
endAdornment: ( | |
<> | |
{isLoading ? <CircularProgress color='inherit' size={20} /> : null} | |
{params.InputProps.endAdornment} | |
</> | |
), | |
}} | |
/> | |
)} | |
{...other} | |
/> | |
); | |
}; | |
export default AutocompleteRelay; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { forwardRef } from 'react'; | |
import { useTheme } from '@material-ui/core/styles'; | |
import useMediaQuery from '@material-ui/core/useMediaQuery'; | |
import ListSubheader from '@material-ui/core/ListSubheader'; | |
import { FixedSizeList } from 'react-window'; | |
import InfiniteLoader from 'react-window-infinite-loader'; | |
const LISTBOX_PADDING = 8; // px | |
const OuterElementContext = React.createContext({}); | |
const OuterElementType = React.forwardRef((props, ref) => { | |
const outerProps = React.useContext(OuterElementContext); | |
return <div ref={ref} {...props} {...outerProps} />; | |
}); | |
// Adapter for react-window | |
const ListboxComponent = forwardRef(function ListboxComponent(props, ref) { | |
const { | |
children, | |
// itemCount, | |
isItemLoaded, | |
loadMoreItems, | |
itemCount, | |
isLoadingMore, | |
...other | |
} = props; | |
const itemData = React.Children.toArray(children); | |
const theme = useTheme(); | |
const smUp = useMediaQuery(theme.breakpoints.up('sm'), { noSsr: true }); | |
// itemCount is based on connection | |
// const itemCount = itemData.length; | |
const itemSize = smUp ? 36 : 48; | |
const getChildSize = (child) => { | |
if (React.isValidElement(child) && child.type === ListSubheader) { | |
return 48; | |
} | |
return itemSize; | |
}; | |
const getHeight = (): number => { | |
if (itemCount > 8) { | |
return 8 * itemSize; | |
} | |
return itemData.map(getChildSize).reduce((a, b) => a + b, 0); | |
}; | |
const renderRow = (props) => { | |
const { data, index, style } = props; | |
if (!isItemLoaded(index)) { | |
// TODO - improve loading state | |
return null; | |
// return <li style={style}>Loading...</li>; | |
} | |
if (!data[index]) { | |
// eslint-disable-next-line | |
console.log('isLoaded but no data', { data, index }); | |
return null; | |
} | |
return React.cloneElement(data[index], { | |
style: { | |
...style, | |
top: style.top + LISTBOX_PADDING, | |
}, | |
}); | |
}; | |
return ( | |
<div ref={ref}> | |
<OuterElementContext.Provider value={other}> | |
<InfiniteLoader isItemLoaded={isItemLoaded} itemCount={itemCount} loadMoreItems={loadMoreItems}> | |
{({ onItemsRendered, ref: refList }) => ( | |
<FixedSizeList | |
ref={refList} | |
itemData={itemData} | |
height={getHeight() + 2 * LISTBOX_PADDING} | |
width='100%' | |
key={itemCount} | |
outerElementType={OuterElementType} | |
innerElementType='ul' | |
itemSize={itemSize} | |
overscanCount={5} | |
itemCount={itemCount} | |
onItemsRendered={onItemsRendered} | |
> | |
{renderRow} | |
</FixedSizeList> | |
)} | |
</InfiniteLoader> | |
</OuterElementContext.Provider> | |
</div> | |
); | |
}); | |
export default ListboxComponent; |
Can you share a codesandbox?
Hey there sure AutoComplete React Window Implementation
the problem is that is not loading more data?
Its not really about the data, its about the dropdown. If you try to navigate between the option with key up and key down. It wouldn’t scrolling. Thats the problem
I think this may help you:
hi there, the autocomplete react window component is great, but, is there a way to implement some kind of scroll to the selected item when clicking to open the dropdown? something based on this https://react-window.vercel.app/#/examples/list/scroll-to-item
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Hey there author ! I referred to your component to integrate react window with mui autocomplete. but i cant navigate the listbox apparently even though the role has been properly assigned to outer component. Do you have any idea whats going on ?