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

Auto-update menu position when using menu portalling #5256

Merged
merged 16 commits into from Oct 12, 2022
5 changes: 5 additions & 0 deletions .changeset/soft-bags-shave.md
@@ -0,0 +1,5 @@
---
'react-select': minor
---

Auto-update menu position when using menu portalling
4 changes: 3 additions & 1 deletion packages/react-select/package.json
Expand Up @@ -13,10 +13,12 @@
"@babel/runtime": "^7.12.0",
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.8.1",
"@floating-ui/dom": "^1.0.1",
"@types/react-transition-group": "^4.4.0",
"memoize-one": "^5.0.0",
"prop-types": "^15.6.0",
"react-transition-group": "^4.3.0"
"react-transition-group": "^4.3.0",
"use-isomorphic-layout-effect": "^1.1.2"
},
"devDependencies": {
"@types/jest-in-case": "^1.0.3",
Expand Down
201 changes: 134 additions & 67 deletions packages/react-select/src/components/Menu.tsx
Expand Up @@ -5,17 +5,21 @@ import {
ReactNode,
RefCallback,
ContextType,
useState,
useCallback,
useRef,
} from 'react';
import { jsx } from '@emotion/react';
import { createPortal } from 'react-dom';
import { autoUpdate } from '@floating-ui/dom';
import useLayoutEffect from 'use-isomorphic-layout-effect';

import {
animatedScrollTo,
getBoundingClientObj,
getScrollParent,
getScrollTop,
normalizedHeight,
RectType,
scrollTo,
} from '../utils';
import {
Expand All @@ -36,8 +40,8 @@ import {
// Get Menu Placement
// ------------------------------

interface MenuState {
placement: CoercedMenuPlacement | null;
interface CalculatedMenuPlacementAndHeight {
placement: CoercedMenuPlacement;
maxHeight: number;
}
interface PlacementArgs {
Expand All @@ -58,10 +62,13 @@ export function getMenuPlacement({
shouldScroll,
isFixedPosition,
theme,
}: PlacementArgs): MenuState {
}: PlacementArgs): CalculatedMenuPlacementAndHeight {
const { spacing } = theme;
const scrollParent = getScrollParent(menuEl!);
const defaultState: MenuState = { placement: 'bottom', maxHeight };
const defaultState: CalculatedMenuPlacementAndHeight = {
placement: 'bottom',
maxHeight,
};

// something went wrong, return default state
if (!menuEl || !menuEl.offsetParent) return defaultState;
Expand Down Expand Up @@ -288,9 +295,16 @@ export const menuCSS = <
});

const PortalPlacementContext = createContext<{
getPortalPlacement: ((menuState: MenuState) => void) | null;
getPortalPlacement:
| ((menuState: CalculatedMenuPlacementAndHeight) => void)
| null;
}>({ getPortalPlacement: null });

interface MenuState {
placement: CoercedMenuPlacement | null;
maxHeight: number;
}

// NOTE: internal only
export class MenuPlacer<
Option,
Expand Down Expand Up @@ -539,14 +553,10 @@ export interface MenuPortalProps<
menuPosition: MenuPosition;
}

interface MenuPortalState {
placement: 'bottom' | 'top' | null;
}

export interface PortalStyleArgs {
offset: number;
position: MenuPosition;
rect: RectType;
rect: { left: number; width: number };
}

export const menuPortalCSS = ({
Expand All @@ -561,69 +571,126 @@ export const menuPortalCSS = ({
zIndex: 1,
});

export class MenuPortal<
interface ComputedPosition {
offset: number;
rect: { left: number; width: number };
}

export const MenuPortal = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
> extends Component<MenuPortalProps<Option, IsMulti, Group>, MenuPortalState> {
state: MenuPortalState = { placement: null };
>({
appendTo,
children,
className,
controlElement,
cx,
innerProps,
menuPlacement,
menuPosition,
getStyles,
}: MenuPortalProps<Option, IsMulti, Group>) => {
const menuPortalRef = useRef<HTMLDivElement | null>(null);
const cleanupRef = useRef<(() => void) | void | null>(null);

const [placement, setPlacement] = useState<'bottom' | 'top'>(
coercePlacement(menuPlacement)
);
const [computedPosition, setComputedPosition] =
useState<ComputedPosition | null>(null);

// callback for occasions where the menu must "flip"
getPortalPlacement = ({ placement }: MenuState) => {
const initialPlacement = coercePlacement(this.props.menuPlacement);
const getPortalPlacement = useCallback(
({ placement: updatedPlacement }: CalculatedMenuPlacementAndHeight) => {
// avoid re-renders if the placement has not changed
if (updatedPlacement !== placement) {
setPlacement(updatedPlacement);
}
},
[placement]
);

const updateComputedPosition = useCallback(() => {
if (!controlElement) return;

// avoid re-renders if the placement has not changed
if (placement !== initialPlacement) {
this.setState({ placement });
const rect = getBoundingClientObj(controlElement);
const scrollDistance = menuPosition === 'fixed' ? 0 : window.pageYOffset;
const offset = rect[placement] + scrollDistance;
if (
offset !== computedPosition?.offset ||
rect.left !== computedPosition?.rect.left ||
rect.width !== computedPosition?.rect.width
) {
setComputedPosition({ offset, rect });
}
}, [
controlElement,
menuPosition,
placement,
computedPosition?.offset,
computedPosition?.rect.left,
computedPosition?.rect.width,
]);

useLayoutEffect(() => {
updateComputedPosition();
}, [updateComputedPosition]);

const runAutoUpdate = useCallback(() => {
if (typeof cleanupRef.current === 'function') {
cleanupRef.current();
cleanupRef.current = null;
}
};
render() {
const {
appendTo,
children,
className,
controlElement,
cx,
innerProps,
menuPlacement,
menuPosition: position,
getStyles,
} = this.props;
const isFixed = position === 'fixed';

// bail early if required elements aren't present
if ((!appendTo && !isFixed) || !controlElement) {
return null;
if (controlElement && menuPortalRef.current) {
cleanupRef.current = autoUpdate(
controlElement,
menuPortalRef.current,
updateComputedPosition
);
}
}, [controlElement, updateComputedPosition]);

useLayoutEffect(() => {
runAutoUpdate();
}, [runAutoUpdate]);

const setMenuPortalElement = useCallback(
(menuPortalElement: HTMLDivElement) => {
menuPortalRef.current = menuPortalElement;
runAutoUpdate();
},
[runAutoUpdate]
);

const placement = this.state.placement || coercePlacement(menuPlacement);
const rect = getBoundingClientObj(controlElement);
const scrollDistance = isFixed ? 0 : window.pageYOffset;
const offset = rect[placement] + scrollDistance;
const state = { offset, position, rect };

// same wrapper element whether fixed or portalled
const menuWrapper = (
<div
css={getStyles('menuPortal', state)}
className={cx(
{
'menu-portal': true,
},
className
)}
{...innerProps}
>
{children}
</div>
);

return (
<PortalPlacementContext.Provider
value={{ getPortalPlacement: this.getPortalPlacement }}
>
{appendTo ? createPortal(menuWrapper, appendTo) : menuWrapper}
</PortalPlacementContext.Provider>
);
}
}
// bail early if required elements aren't present
if ((!appendTo && menuPosition !== 'fixed') || !computedPosition) return null;

// same wrapper element whether fixed or portalled
const menuWrapper = (
<div
ref={setMenuPortalElement}
css={getStyles('menuPortal', {
offset: computedPosition.offset,
position: menuPosition,
rect: computedPosition.rect,
})}
className={cx(
{
'menu-portal': true,
},
className
)}
{...innerProps}
>
{children}
</div>
);

return (
<PortalPlacementContext.Provider value={{ getPortalPlacement }}>
{appendTo ? createPortal(menuWrapper, appendTo) : menuWrapper}
</PortalPlacementContext.Provider>
);
};