Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Virtualize dropdown options list #284

Merged
merged 21 commits into from
Aug 12, 2021
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e6aad39
Prevent generating duplicate cities
dawsonbooth Aug 9, 2021
80ce173
Show value in `onSelect` action
dawsonbooth Aug 9, 2021
0004a9b
Fix placeholder for `Icons` story
dawsonbooth Aug 9, 2021
805ed04
feat(dropdown): virtualize options list for better performance of lon…
dawsonbooth Aug 9, 2021
3981619
Merge branch 'master' into 184-virtual-dropdown
dawsonbooth Aug 9, 2021
37802f2
#184 Fix keyboard navigation
dawsonbooth Aug 10, 2021
a66adf5
#184 Set `initialItemCount` to pass tests
dawsonbooth Aug 10, 2021
059d4fe
Fix duplicate options prevention
dawsonbooth Aug 10, 2021
8727658
#184 Only virtualize options when DOM is available
dawsonbooth Aug 11, 2021
c0167d5
Fix dropdown stories style and performance
dawsonbooth Aug 11, 2021
449bf70
#184 Modify number of options to render initially
dawsonbooth Aug 11, 2021
cfa9fca
#184 Remember scroll position after close
dawsonbooth Aug 11, 2021
e32d398
#184 Remove virtualization if less than max height
dawsonbooth Aug 12, 2021
f79835e
#184 Adjust naming for nested elements on keypress
dawsonbooth Aug 12, 2021
823cb18
Improve city option item variable naming
dawsonbooth Aug 12, 2021
2aa71d2
Improve performance of city option generation
dawsonbooth Aug 12, 2021
004ed15
#184 Add state for whether to virtualize options
dawsonbooth Aug 12, 2021
ffe6cb3
#184 Refactor keypress handler and nested scroller
dawsonbooth Aug 12, 2021
31fcd45
#184 Remember scroll position for non-virtual list
dawsonbooth Aug 12, 2021
11e7cd4
#184 Remove extra timeout and document navigation
dawsonbooth Aug 12, 2021
2c9c7a0
#184 Add open/close timeout to improve performance
dawsonbooth Aug 12, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 13 additions & 10 deletions src/components/Dropdown/Dropdown.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,22 @@ import Label from '../Label';
import { colors } from '../../index';

const generateCityList = (amount: number): OptionProps[] => {
const tempData: string[] = [];
const citySet = new Set<string>();
const cityOptions: OptionProps[] = [];

for (let i = 0; i < amount; i += 1) {
let item = address.city();
while (tempData.includes(item)) {
item = address.city();
let city = address.city();
while (citySet.has(city)) {
city = address.city();
}
tempData.push(item);
citySet.add(city);
cityOptions.push({
id: city.toLowerCase(),
optionValue: city,
});
}

return tempData.map(item => ({
id: item.toLowerCase(),
optionValue: item,
}));
return cityOptions;
};

type BasicProps = DropdownProps & { clearable: boolean; numCities: number };
Expand Down Expand Up @@ -69,7 +71,8 @@ Basic.args = {
variant: variants.fill,
optionsVariant: variants.outline,
valueVariant: variants.text,
numCities: 20000,
numCities: 200,
virtualizeOptions: true,
};

const teaOptions = [
Expand Down
242 changes: 173 additions & 69 deletions src/components/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,12 @@ const ValueItem = styled(Div)`
`;

const OptionsContainer = styled(Div)`
${({ color, variant }: UsefulDropdownState) => `
${({ color, variant, isVirtual }: UsefulDropdownState & { isVirtual: boolean }) => `
background: white;
position: absolute;
top: 100%;
left: 0px;
height: 10rem;
${isVirtual ? 'height: 10rem;' : 'max-height: 10rem;'}
overflow-y: auto;
width: 15rem;
${
Expand Down Expand Up @@ -239,7 +239,7 @@ export interface DropdownProps {
optionsVariant?: variants;
valueVariant?: variants;

initialOptionCount?: number;
virtualizeOptions?: boolean;
}

const Dropdown = ({
Expand Down Expand Up @@ -292,7 +292,7 @@ const Dropdown = ({
valueVariant = variants.text,
values = [],

initialOptionCount,
virtualizeOptions = true,
}: DropdownProps): JSX.Element | null => {
const { colors } = useTheme();
const defaultedColor = color || colors.grayDark;
Expand All @@ -303,8 +303,17 @@ const Dropdown = ({
const [focusWithin, setFocusWithin] = useState<boolean>(false);
const [focusTimeoutId, setFocusTimeoutId] = useState<number>();

const scrollPos = useRef<number>(0);

const [scrollIndex, setScrollIndex] = useState<number>(0);

const [isOverflowing, setIsOverflowing] = useState<boolean>(true);
const [isVirtual, setIsVirtual] = useState<boolean>(virtualizeOptions);

useEffect(() => {
setIsVirtual(virtualizeOptions && isOverflowing);
}, [virtualizeOptions, isOverflowing]);

// Merge the default styled container prop and the placeholderProps object to get user styles
const placeholderMergedProps = {
StyledContainer: PlaceholderContainer,
Expand All @@ -322,6 +331,12 @@ const Dropdown = ({
return hash;
}, [options, values]);

const scrollListener = () => {
scrollPos.current = optionsContainerInternalRef.current
? optionsContainerInternalRef.current.scrollTop
: 0;
};

const handleBlur = useCallback(
(e: React.FocusEvent) => {
e.preventDefault();
Expand Down Expand Up @@ -349,10 +364,32 @@ const Dropdown = ({
}

setIsOpen(true);

window.setTimeout(() => {
const focusedElement = document.activeElement;

if (focusedElement && focusedElement.id === `${name}-dropdown-button`) {
const button = focusedElement.parentNode as HTMLElement | undefined;
const optionsContainer = button ? button.nextElementSibling : null;
aVileBroker marked this conversation as resolved.
Show resolved Hide resolved

if (optionsContainer) {
if (isVirtual) {
const virtuosoContainer = optionsContainer.firstElementChild;
const virtuosoScroller = virtuosoContainer?.firstElementChild;
if (virtuosoScroller && virtuosoScroller.clientHeight < optionsContainer.clientHeight) {
setIsOverflowing(false);
}
} else if (optionsContainer.scrollHeight > optionsContainer.clientHeight) {
setIsOverflowing(true);
}
}
}
}, 0);

if (onFocus) {
onFocus();
}
}, [onFocus, focusWithin, focusTimeoutId]);
}, [focusTimeoutId, focusWithin, onFocus, name, isVirtual]);

const handleSelect = useCallback(
(clickedId: string | number) => {
Expand Down Expand Up @@ -401,6 +438,7 @@ const Dropdown = ({
// to activeElement to after it is updated in the DOM
window.setTimeout(() => {
const focusedElement = document.activeElement;

switch (key) {
case 'Enter':
const match = focusedElement && focusedElement.id.match(`${name}-option-(.*)`);
Expand All @@ -410,13 +448,19 @@ const Dropdown = ({
break;
case 'ArrowUp':
if (focusedElement && focusedElement.id.match(`${name}-option-.*`)) {
const row = focusedElement.parentNode as HTMLElement | undefined;
const rowPrevSibling = row ? row.previousElementSibling : null;
if (rowPrevSibling) {
const toFocus = rowPrevSibling.children[0] as HTMLElement | undefined;
if (toFocus) {
toFocus.focus();
let prevOption;
if (isVirtual) {
const row = focusedElement.parentNode as HTMLElement | undefined;
const rowPrevSibling = row ? row.previousElementSibling : null;
if (rowPrevSibling) {
prevOption = rowPrevSibling.firstElementChild as HTMLElement | undefined;
}
} else {
prevOption = focusedElement.previousElementSibling as HTMLElement | null;
}

if (prevOption) {
prevOption.focus();
}
}
break;
Expand All @@ -426,20 +470,33 @@ const Dropdown = ({
// get parent before nextElementSibling because buttons are nested inside of skeletons
const optionsContainer = button ? button.nextElementSibling : null;
if (optionsContainer) {
const toFocus = optionsContainer.children[0]?.children[0]?.children[0]
?.children[0] as HTMLElement | undefined;
if (toFocus) {
toFocus.focus();
let firstOption;
if (isVirtual) {
const virtuosoContainer = optionsContainer.firstElementChild;
const virtuosoScroller = virtuosoContainer?.firstElementChild;
firstOption = virtuosoScroller?.firstElementChild?.firstElementChild as
| HTMLElement
| undefined;
} else {
firstOption = optionsContainer.firstElementChild as HTMLElement | undefined;
}
if (firstOption) {
firstOption.focus();
}
}
} else if (focusedElement && focusedElement.id.match(`${name}-option-.*`)) {
const row = focusedElement.parentNode as HTMLElement | undefined;
const rowNextSibling = row ? row.nextElementSibling : null;
if (rowNextSibling) {
const toFocus = rowNextSibling.children[0] as HTMLElement | undefined;
if (toFocus) {
toFocus.focus();
let nextOption;
if (isVirtual) {
const row = focusedElement.parentNode as HTMLElement | undefined;
const rowNextSibling = row ? row.nextElementSibling : null;
if (rowNextSibling) {
nextOption = rowNextSibling.firstElementChild as HTMLElement | undefined;
}
} else {
nextOption = focusedElement.nextElementSibling as HTMLElement | null;
}
if (nextOption) {
nextOption.focus();
}
}
break;
Expand All @@ -448,7 +505,7 @@ const Dropdown = ({
}
}, 0);
},
[handleSelect, name],
[handleSelect, isVirtual, name],
);

useEffect(() => {
Expand All @@ -459,6 +516,19 @@ const Dropdown = ({
};
}, [keyDownHandler]);

const optionsScrollListenerCallbackRef = useCallback(
node => {
if (node && rememberScrollPosition) {
node.addEventListener('scroll', scrollListener, true);

if (scrollPos.current) {
node.scrollTop = scrollPos.current;
}
}
},
[rememberScrollPosition],
);

const closeIcons = (
<>
{onClear && values.length > 0 && (
Expand All @@ -478,12 +548,13 @@ const Dropdown = ({
</>
);

const VirtuosoComponents = useMemo(
() => ({
Scroller: React.forwardRef(({ children }: { children: React.ReactNode }, listRef) => (
const InternalOptionsContainer = useMemo(
() =>
React.forwardRef(({ children }: { children: React.ReactNode }, listRef) => (
<StyledOptionsContainer
color={defaultedColor}
variant={optionsVariant}
isVirtual={isVirtual}
role="listbox"
ref={mergeRefs([
optionsContainerRef,
Expand All @@ -495,8 +566,7 @@ const Dropdown = ({
{children}
</StyledOptionsContainer>
)),
}),
[defaultedColor, optionsContainerProps, optionsContainerRef, optionsVariant],
[defaultedColor, isVirtual, optionsContainerProps, optionsContainerRef, optionsVariant],
);

return (
Expand Down Expand Up @@ -561,48 +631,82 @@ const Dropdown = ({
</StyledValueItem>
{closeIcons}
</Button>
{isOpen && (
<Virtuoso
data={options}
rangeChanged={(range: ListRange) => setScrollIndex(range.startIndex)}
initialTopMostItemIndex={rememberScrollPosition ? scrollIndex : 0}
initialItemCount={
typeof window !== 'undefined' && window.document && window.document.createElement
? initialOptionCount
: options.length
}
components={VirtuosoComponents as Components}
itemContent={(_index, option) => (
<StyledOptionItem
id={`${name}-option-${option.id}`}
key={`${name}-option-${option.id}`}
onClick={() => handleSelect(option.id)}
tabIndex={-1}
color={defaultedColor}
variant={optionsVariant}
multi={multi}
selected={optionsHash[option.id].isSelected}
ref={optionItemRef}
role="option"
{...optionItemProps}
>
{multi && (
<StyledCheckContainer
color={defaultedColor}
selected={optionsHash[option.id].isSelected}
variant={optionsVariant}
multi={multi}
ref={checkContainerRef}
{...checkContainerProps}
>
{optionsHash[option.id].isSelected && <Icon path={mdiCheck} size="1em" />}
</StyledCheckContainer>
)}
<Span>{option.optionValue}</Span>
</StyledOptionItem>
)}
/>
)}
{isOpen &&
(isVirtual ? (
<Virtuoso
data={options}
rangeChanged={(range: ListRange) => setScrollIndex(range.startIndex)}
initialTopMostItemIndex={
rememberScrollPosition && scrollIndex < options.length ? scrollIndex : 0
}
components={
{
Scroller: InternalOptionsContainer,
} as Components
}
itemContent={(_index, option) => (
<StyledOptionItem
id={`${name}-option-${option.id}`}
key={`${name}-option-${option.id}`}
onClick={() => handleSelect(option.id)}
tabIndex={-1}
color={defaultedColor}
variant={optionsVariant}
multi={multi}
selected={optionsHash[option.id].isSelected}
ref={optionItemRef}
role="option"
{...optionItemProps}
>
{multi && (
<StyledCheckContainer
color={defaultedColor}
selected={optionsHash[option.id].isSelected}
variant={optionsVariant}
multi={multi}
ref={checkContainerRef}
{...checkContainerProps}
>
{optionsHash[option.id].isSelected && <Icon path={mdiCheck} size="1em" />}
</StyledCheckContainer>
)}
<Span>{option.optionValue}</Span>
</StyledOptionItem>
)}
/>
) : (
<InternalOptionsContainer ref={optionsScrollListenerCallbackRef}>
{options.map(option => (
<StyledOptionItem
id={`${name}-option-${option.id}`}
key={`${name}-option-${option.id}`}
onClick={() => handleSelect(option.id)}
tabIndex={-1}
color={defaultedColor}
variant={optionsVariant}
multi={multi}
selected={optionsHash[option.id].isSelected}
ref={optionItemRef}
role="option"
{...optionItemProps}
>
{multi && (
<StyledCheckContainer
color={defaultedColor}
selected={optionsHash[option.id].isSelected}
variant={optionsVariant}
multi={multi}
ref={checkContainerRef}
{...checkContainerProps}
>
{optionsHash[option.id].isSelected && <Icon path={mdiCheck} size="1em" />}
</StyledCheckContainer>
)}
<Span>{option.optionValue}</Span>
</StyledOptionItem>
))}
</InternalOptionsContainer>
))}
</StyledContainer>
);
};
Expand Down
Loading