Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
@material-ui Autocomplete lab with react-window + infinite-loader for GraphQL/Relay connections
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;
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;
@Hud97
Copy link

Hud97 commented Jul 4, 2022

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 ?

@sibelius
Copy link
Author

sibelius commented Jul 4, 2022

Can you share a codesandbox?

@Hud97
Copy link

Hud97 commented Jul 5, 2022

@sibelius
Copy link
Author

sibelius commented Jul 5, 2022

the problem is that is not loading more data?

@Hud97
Copy link

Hud97 commented Jul 5, 2022

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

@LeAlencar
Copy link

LeAlencar commented Jul 5, 2022

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment