Skip to content

Commit

Permalink
Auto-update menu position when using menu portalling (#5256)
Browse files Browse the repository at this point in the history
* yarn install

* Add floating-ui

* Make MenuPortal function component

* Fix csstype resolution

* Update

* Remove unnecessary export

* Avoid ResizeObserver

* Create soft-bags-shave.md

* Include fixed

* Update

* Update

* Fix

* Bump @floating-ui/dom

* Format
  • Loading branch information
Methuselah96 committed Oct 12, 2022
1 parent 9601502 commit 598f9ee
Show file tree
Hide file tree
Showing 4 changed files with 230 additions and 71 deletions.
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>
);
};

0 comments on commit 598f9ee

Please sign in to comment.