Skip to content

Commit

Permalink
Clean up Popover positioning logic
Browse files Browse the repository at this point in the history
  • Loading branch information
stephl3 committed Aug 19, 2024
1 parent b58cc68 commit 75edb2e
Show file tree
Hide file tree
Showing 4 changed files with 341 additions and 173 deletions.
270 changes: 270 additions & 0 deletions packages/popover/src/Popover.hooks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
import React, { forwardRef, ReactNode, useMemo, useRef, useState } from 'react';

import {
useIsomorphicLayoutEffect,
useMutationObserver,
useObjectDependency,
usePrevious,
useViewportSize,
} from '@leafygreen-ui/hooks';

import {
getElementDocumentPosition,
getElementViewportPosition,
} from './utils/positionUtils';
import { contentClassName, hiddenPlaceholderStyle } from './Popover.styles';
import { Align, Justify, PopoverProps } from './Popover.types';

const mutationOptions = {
// If attributes changes, such as className which affects layout
attributes: true,
// Watch if text changes in the node
characterData: true,
// Watch for any immediate children are modified
childList: true,
// Extend watching to entire sub tree to make sure we catch any modifications
subtree: true,
};

interface UseReferenceElementReturnObj {
/**
* `HiddenPlaceholder` is used if `refEl` is undefined. The placeholder's parent is
* used as the element against which the popover component will be positioned
*/
HiddenPlaceholder: React.ForwardRefExoticComponent<
React.RefAttributes<HTMLSpanElement>
>;

/**
* Ref to access hidden placeholder element
*/
placeholderRef: React.MutableRefObject<HTMLSpanElement | null>;

/**
* Element against which the popover component will be positioned
*/
referenceElement: HTMLElement | null;

/**
* Boolean to determine if a hidden placeholder should be rendered
*/
renderHiddenPlaceholder: boolean;
}

export function useReferenceElement(
refEl?: PopoverProps['refEl'],
): UseReferenceElementReturnObj {
const placeholderRef = useRef<HTMLSpanElement | null>(null);

const referenceElement = useMemo(() => {
if (refEl && refEl.current) {
return refEl.current;
}

const placeholderEl = placeholderRef?.current;
const maybeParentEl = placeholderEl !== null && placeholderEl?.parentNode;

if (maybeParentEl && maybeParentEl instanceof HTMLElement) {
return maybeParentEl;
}

return null;
}, [placeholderRef.current, refEl?.current]);

return {
HiddenPlaceholder,
placeholderRef,
referenceElement,
renderHiddenPlaceholder: !refEl,
};
}

const HiddenPlaceholder = forwardRef<HTMLSpanElement, {}>((_, fwdRef) => {
/**
* Using \<span\> as placeholder to prevent validateDOMNesting warnings
* Warnings will still show up if `usePortal` is false
*/
return <span ref={fwdRef} className={hiddenPlaceholderStyle} />;
});

HiddenPlaceholder.displayName = 'HiddenPlaceholder';

interface UseContentNodeReturnObj {
/**
* `contentNode` is the direct child of the popover element and wraps the children. It
* is used to calculate the position of the popover because its parent has a transition.
* This prevents getting the width of the popover until the transition completes
*/
contentNode: HTMLDivElement | null;

/**
* We shadow the `contentNode` onto this `contentNodeRef` as <Transition> from
* react-transition-group only accepts useRef objects. Without this, StrictMode
* warnings are produced by react-transition-group.
*/
contentNodeRef: React.MutableRefObject<HTMLDivElement | null>;

/**
* `ContentWrapper` is used to wrap the children of the popover component. We need
* an inner wrapper with a ref because placing the ref on the parent will create an
* infinite loop in some cases when dynamic styles are applied.
*/
ContentWrapper: React.ForwardRefExoticComponent<
{
children: ReactNode;
} & React.RefAttributes<HTMLDivElement>
>;

/**
* Dispatch method to attach `contentNode` to the `ContentWrapper`
*/
setContentNode: React.Dispatch<React.SetStateAction<HTMLDivElement | null>>;
}

export function useContentNode(): UseContentNodeReturnObj {
const [contentNode, setContentNode] = React.useState<HTMLDivElement | null>(
null,
);

const contentNodeRef = useRef(contentNode);
contentNodeRef.current = contentNode;

return {
contentNode,
contentNodeRef,
ContentWrapper,
setContentNode,
};
}

const ContentWrapper = forwardRef<HTMLDivElement, { children: ReactNode }>(
({ children }, fwdRef) => {
return (
<div ref={fwdRef} className={contentClassName}>
{children}
</div>
);
},
);

ContentWrapper.displayName = 'ContentWrapper';

type UsePopoverPositioningProps = Pick<
PopoverProps,
'active' | 'adjustOnMutation' | 'align' | 'justify' | 'scrollContainer'
> & {
contentNode: HTMLDivElement | null;
referenceElement: HTMLElement | null;
};

export function usePopoverPositioning({
active,
adjustOnMutation,
align = Align.Bottom,
contentNode,
justify = Justify.Start,
referenceElement,
scrollContainer,
}: UsePopoverPositioningProps) {
/**
* Don't render the popover initially since computing the position depends on the window
* which isn't available if the component is rendered on server side.
*/
const [isReadyToRender, setIsReadyToRender] = useState(false);
const [forceUpdateCounter, setForceUpdateCounter] = useState(0);

/**
* We calculate the position of the popover when it becomes active, so it's only safe
* for us to enable the mutation observers once the popover is active.
*/
const observeMutations = adjustOnMutation && active;

const viewportSize = useViewportSize();

const lastTimeRefElMutated = useMutationObserver(
referenceElement,
mutationOptions,
Date.now,
observeMutations,
);

const lastTimeContentElMutated = useMutationObserver(
contentNode?.parentNode as HTMLElement,
mutationOptions,
Date.now,
observeMutations,
);

// We don't memoize these values as they're reliant on scroll positioning
const referenceElViewportPos = useObjectDependency(
getElementViewportPosition(referenceElement, scrollContainer, true),
);

// We use contentNode.parentNode since the parentNode has a transition applied to it and we want to be able to get the width of this element before it is transformed. Also as noted below, the parentNode cannot have a ref on it.
// Previously the contentNode was passed in but since it is a child of transformed element it was not possible to get an untransformed width.
const contentElViewportPos = useObjectDependency(
getElementViewportPosition(
contentNode?.parentNode as HTMLElement,
scrollContainer,
),
);

const referenceElDocumentPos = useObjectDependency(
useMemo(
() => getElementDocumentPosition(referenceElement, scrollContainer, true),
[
referenceElement,
scrollContainer,
viewportSize,
lastTimeRefElMutated,
active,
align,
justify,
forceUpdateCounter,
],
),
);

const contentElDocumentPos = useObjectDependency(
useMemo(
() => getElementDocumentPosition(contentNode),
[
contentNode?.parentNode,
viewportSize,
lastTimeContentElMutated,
active,
align,
justify,
forceUpdateCounter,
],
),
);

const prevJustify = usePrevious<Justify>(justify);
const prevAlign = usePrevious<Align>(align);

const layoutMightHaveChanged =
(prevJustify !== justify &&
(justify === Justify.Fit || prevJustify === Justify.Fit)) ||
(prevAlign !== align && justify === Justify.Fit);

useIsomorphicLayoutEffect(() => {
// justify={Justify.Fit} can cause the content's height/width to change
// If we're switching to/from Fit, force an extra pass to make sure the popover is positioned correctly.
// Also if we're switching between alignments and have Justify.Fit, it may switch between setting the width and
// setting the height, so force an update in that case as well.
if (layoutMightHaveChanged) {
setForceUpdateCounter(n => n + 1);
}
}, [layoutMightHaveChanged]);

useIsomorphicLayoutEffect(() => setIsReadyToRender(true), []);

return {
contentElDocumentPos,
contentElViewportPos,
isReadyToRender,
referenceElDocumentPos,
referenceElViewportPos,
};
}
22 changes: 16 additions & 6 deletions packages/popover/src/Popover.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { css, cx } from '@leafygreen-ui/emotion';
import { createUniqueClassName } from '@leafygreen-ui/lib';
import { transitionDuration } from '@leafygreen-ui/tokens';

import { AbsolutePositionObject } from './utils/positionUtils';

export const TRANSITION_DURATION = transitionDuration.default;

export const contentClassName = createUniqueClassName('popover-content');
Expand All @@ -12,8 +14,15 @@ export const hiddenPlaceholderStyle = css`
display: none;
`;

const basePopoverStyles = css`
type PositionCSS = AbsolutePositionObject & { transformOrigin: string };

const getBasePopoverStyles = (positionCSS: PositionCSS) => css`
position: absolute;
top: ${positionCSS.top};
left: ${positionCSS.left};
right: ${positionCSS.right};
bottom: ${positionCSS.bottom};
transform-origin: ${positionCSS.transformOrigin};
transition-property: opacity, transform;
transition-duration: ${TRANSITION_DURATION}ms;
transition-timing-function: ease-in-out;
Expand All @@ -36,16 +45,17 @@ export const getPopoverStyles = ({
}: {
className?: string;
popoverZIndex?: number;
positionCSS: any;
positionCSS: PositionCSS;
state: TransitionStatus;
transform: any;
transform: string;
usePortal: boolean;
}) =>
cx(
basePopoverStyles,
css(positionCSS),
getBasePopoverStyles(positionCSS),
{
[css({ transform })]: state === 'entering' || state === 'exiting',
[css`
transform: ${transform};
`]: state === 'entering' || state === 'exiting',
[getActiveStyles(usePortal)]: state === 'entered',
[css`
z-index: ${popoverZIndex};
Expand Down
Loading

0 comments on commit 75edb2e

Please sign in to comment.