Skip to content

Commit

Permalink
Refactor MenuPlacer to functional component
Browse files Browse the repository at this point in the history
  • Loading branch information
nderkim committed Oct 26, 2022
1 parent 4b14dca commit 61f8ee9
Showing 1 changed file with 79 additions and 85 deletions.
164 changes: 79 additions & 85 deletions packages/react-select/src/components/Menu.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
/** @jsx jsx */
import {
createContext,
Component,
Fragment,
ReactNode,
RefCallback,
ContextType,
useState,
Ref,
useCallback,
useContext,
useRef,
useState,
} from 'react';
import { jsx } from '@emotion/react';
import { createPortal } from 'react-dom';
Expand Down Expand Up @@ -55,10 +55,10 @@ interface PlacementArgs {
}

export function getMenuPlacement({
maxHeight,
maxHeight: desiredMaxHeight,
menuEl,
minHeight,
placement,
placement: desiredPlacement,
shouldScroll,
isFixedPosition,
theme,
Expand All @@ -67,7 +67,7 @@ export function getMenuPlacement({
const scrollParent = getScrollParent(menuEl!);
const defaultState: CalculatedMenuPlacementAndHeight = {
placement: 'bottom',
maxHeight,
maxHeight: desiredMaxHeight,
};

// something went wrong, return default state
Expand Down Expand Up @@ -99,12 +99,12 @@ export function getMenuPlacement({
const scrollUp = scrollTop + menuTop - marginTop;
const scrollDuration = 160;

switch (placement) {
switch (desiredPlacement) {
case 'auto':
case 'bottom':
// 1: the menu will fit, do nothing
if (viewSpaceBelow >= menuHeight) {
return { placement: 'bottom', maxHeight };
return { placement: 'bottom', maxHeight: desiredMaxHeight };
}

// 2: the menu will fit, if scrolled
Expand All @@ -113,7 +113,7 @@ export function getMenuPlacement({
animatedScrollTo(scrollParent, scrollDown, scrollDuration);
}

return { placement: 'bottom', maxHeight };
return { placement: 'bottom', maxHeight: desiredMaxHeight };
}

// 3: the menu will fit, if constrained
Expand All @@ -140,33 +140,33 @@ export function getMenuPlacement({
// 4. Forked beviour when there isn't enough space below

// AUTO: flip the menu, render above
if (placement === 'auto' || isFixedPosition) {
if (desiredPlacement === 'auto' || isFixedPosition) {
// may need to be constrained after flipping
let constrainedHeight = maxHeight;
let constrainedHeight = desiredMaxHeight;
const spaceAbove = isFixedPosition ? viewSpaceAbove : scrollSpaceAbove;

if (spaceAbove >= minHeight) {
constrainedHeight = Math.min(
spaceAbove - marginBottom - spacing.controlHeight,
maxHeight
desiredMaxHeight
);
}

return { placement: 'top', maxHeight: constrainedHeight };
}

// BOTTOM: allow browser to increase scrollable area and immediately set scroll
if (placement === 'bottom') {
if (desiredPlacement === 'bottom') {
if (shouldScroll) {
scrollTo(scrollParent, scrollDown);
}
return { placement: 'bottom', maxHeight };
return { placement: 'bottom', maxHeight: desiredMaxHeight };
}
break;
case 'top':
// 1: the menu will fit, do nothing
if (viewSpaceAbove >= menuHeight) {
return { placement: 'top', maxHeight };
return { placement: 'top', maxHeight: desiredMaxHeight };
}

// 2: the menu will fit, if scrolled
Expand All @@ -175,15 +175,15 @@ export function getMenuPlacement({
animatedScrollTo(scrollParent, scrollUp, scrollDuration);
}

return { placement: 'top', maxHeight };
return { placement: 'top', maxHeight: desiredMaxHeight };
}

// 3: the menu will fit, if constrained
if (
(!isFixedPosition && scrollSpaceAbove >= minHeight) ||
(isFixedPosition && viewSpaceAbove >= minHeight)
) {
let constrainedHeight = maxHeight;
let constrainedHeight = desiredMaxHeight;

// we want to provide as much of the menu as possible to the user,
// so give them whatever is available below rather than the minHeight.
Expand All @@ -209,9 +209,9 @@ export function getMenuPlacement({
// 4. not enough space, the browser WILL NOT increase scrollable area when
// absolutely positioned element rendered above the viewport (only below).
// Flip the menu, render below
return { placement: 'bottom', maxHeight };
return { placement: 'bottom', maxHeight: desiredMaxHeight };
default:
throw new Error(`Invalid placement provided "${placement}".`);
throw new Error(`Invalid placement provided "${desiredPlacement}".`);
}

return defaultState;
Expand Down Expand Up @@ -240,7 +240,7 @@ export interface MenuProps<
> extends CommonPropsAndClassName<Option, IsMulti, Group>,
MenuPlacementProps {
/** Reference to the internal element, consumed by the MenuPlacer component */
innerRef: RefCallback<HTMLDivElement>;
innerRef: Ref<HTMLDivElement>;
innerProps: JSX.IntrinsicElements['div'];
isLoading: boolean;
placement: CoercedMenuPlacement;
Expand All @@ -254,7 +254,7 @@ interface PlacerProps {
}

interface ChildrenProps {
ref: RefCallback<HTMLDivElement>;
ref: Ref<HTMLDivElement>;
placerProps: PlacerProps;
}

Expand Down Expand Up @@ -294,76 +294,81 @@ export const menuCSS = <
zIndex: 1,
});

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

interface MenuState {
placement: CoercedMenuPlacement | null;
maxHeight: number;
}
const PortalPlacementContext =
createContext<
| {
setPortalPlacement: (placement: CoercedMenuPlacement) => void;
}
| undefined
>(undefined);

// NOTE: internal only
export class MenuPlacer<
export const MenuPlacer = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
> extends Component<MenuPlacerProps<Option, IsMulti, Group>, MenuState> {
state: MenuState = {
maxHeight: this.props.maxMenuHeight,
placement: null,
};
static contextType = PortalPlacementContext;
context!: ContextType<typeof PortalPlacementContext>;

getPlacement: RefCallback<HTMLDivElement> = (ref) => {
const {
minMenuHeight,
maxMenuHeight,
menuPlacement,
menuPosition,
menuShouldScrollIntoView,
theme,
} = this.props;
>(
props: MenuPlacerProps<Option, IsMulti, Group>
) => {
const {
children,
minMenuHeight,
maxMenuHeight,
menuPlacement,
menuPosition,
menuShouldScrollIntoView,
theme,
} = props;

if (!ref) return;
const { setPortalPlacement } = useContext(PortalPlacementContext) || {};
const ref = useRef<HTMLDivElement | null>(null);
const [maxHeight, setMaxHeight] = useState(maxMenuHeight);
const [placement, setPlacement] = useState<CoercedMenuPlacement | null>(null);

useLayoutEffect(() => {
const { current } = ref;
if (!current) return;

// DO NOT scroll if position is fixed
const isFixedPosition = menuPosition === 'fixed';
const shouldScroll = menuShouldScrollIntoView && !isFixedPosition;

const state = getMenuPlacement({
maxHeight: maxMenuHeight,
menuEl: ref,
menuEl: current,
minHeight: minMenuHeight,
placement: menuPlacement,
shouldScroll,
isFixedPosition,
theme,
});

const { getPortalPlacement } = this.context;
if (getPortalPlacement) getPortalPlacement(state);

this.setState(state);
};
getUpdatedProps = () => {
const { menuPlacement } = this.props;
const placement = this.state.placement || coercePlacement(menuPlacement);

return { ...this.props, placement, maxHeight: this.state.maxHeight };
};
render() {
const { children } = this.props;
setMaxHeight(state.maxHeight);
setPlacement(state.placement);
setPortalPlacement?.(state.placement);
}, [
maxMenuHeight,
menuPlacement,
menuPosition,
menuShouldScrollIntoView,
minMenuHeight,
setPortalPlacement,
theme,
]);

return children({
ref: this.getPlacement,
placerProps: this.getUpdatedProps(),
});
}
}
return (
<Fragment>
{children({
ref,
placerProps: {
...props,
placement: placement || coercePlacement(menuPlacement),
maxHeight,
},
})}
</Fragment>
);
};

const Menu = <Option, IsMulti extends boolean, Group extends GroupBase<Option>>(
props: MenuProps<Option, IsMulti, Group>
Expand Down Expand Up @@ -398,7 +403,7 @@ export interface MenuListProps<
/** The children to be rendered. */
children: ReactNode;
/** Inner ref to DOM ReactNode */
innerRef: RefCallback<HTMLDivElement>;
innerRef: Ref<HTMLDivElement>;
/** The currently focused option */
focusedOption: Option;
/** Props to be passed to the menu-list wrapper. */
Expand Down Expand Up @@ -594,23 +599,12 @@ export const MenuPortal = <
const menuPortalRef = useRef<HTMLDivElement | null>(null);
const cleanupRef = useRef<(() => void) | void | null>(null);

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

// callback for occasions where the menu must "flip"
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;

Expand Down Expand Up @@ -690,7 +684,7 @@ export const MenuPortal = <
);

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

0 comments on commit 61f8ee9

Please sign in to comment.