Skip to content

Instantly share code, notes, and snippets.

@sibelius
Last active March 23, 2024 10:13
Show Gist options
  • Star 18 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save sibelius/9b89371f2b846dcdb8bed3165ac372c3 to your computer and use it in GitHub Desktop.
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
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 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

@eugeniol2
Copy link

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