-
Notifications
You must be signed in to change notification settings - Fork 4.3k
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
Popover: consistently adjust position on scroll #17867
Changes from 3 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -7,7 +7,7 @@ import classnames from 'classnames'; | |
* WordPress dependencies | ||
*/ | ||
import { useRef, useState, useEffect } from '@wordpress/element'; | ||
import { focus } from '@wordpress/dom'; | ||
import { focus, getRectangleFromRange } from '@wordpress/dom'; | ||
import { ESCAPE } from '@wordpress/keycodes'; | ||
import isShallowEqual from '@wordpress/is-shallow-equal'; | ||
import deprecated from '@wordpress/deprecated'; | ||
|
@@ -62,48 +62,135 @@ function useThrottledWindowScrollOrResize( handler, ignoredScrollableRef ) { | |
}, [] ); | ||
} | ||
|
||
function computeAnchorRect( | ||
anchorRefFallback, | ||
anchorRect, | ||
getAnchorRect, | ||
anchorRef = false, | ||
shouldAnchorIncludePadding | ||
) { | ||
if ( anchorRect ) { | ||
return anchorRect; | ||
} | ||
|
||
if ( getAnchorRect ) { | ||
if ( ! anchorRefFallback.current ) { | ||
return; | ||
} | ||
|
||
return getAnchorRect( anchorRefFallback.current ); | ||
} | ||
|
||
if ( anchorRef !== false ) { | ||
if ( ! anchorRef ) { | ||
return; | ||
} | ||
|
||
if ( anchorRef instanceof window.Range ) { | ||
return getRectangleFromRange( anchorRef ); | ||
} | ||
|
||
const rect = anchorRef.getBoundingClientRect(); | ||
|
||
if ( shouldAnchorIncludePadding ) { | ||
return rect; | ||
} | ||
|
||
return withoutPadding( rect, anchorRef ); | ||
} | ||
|
||
if ( ! anchorRefFallback.current ) { | ||
return; | ||
} | ||
|
||
const { parentNode } = anchorRefFallback.current; | ||
const rect = parentNode.getBoundingClientRect(); | ||
|
||
if ( shouldAnchorIncludePadding ) { | ||
return rect; | ||
} | ||
|
||
return withoutPadding( rect, parentNode ); | ||
} | ||
|
||
function addBuffer( rect, verticalBuffer = 0, horizontalBuffer = 0 ) { | ||
return { | ||
x: rect.left - horizontalBuffer, | ||
y: rect.top - verticalBuffer, | ||
width: rect.width + ( 2 * horizontalBuffer ), | ||
height: rect.height + ( 2 * verticalBuffer ), | ||
left: rect.left - horizontalBuffer, | ||
right: rect.right + horizontalBuffer, | ||
top: rect.top - verticalBuffer, | ||
bottom: rect.bottom + verticalBuffer, | ||
}; | ||
} | ||
|
||
function withoutPadding( rect, element ) { | ||
const { | ||
paddingTop, | ||
paddingBottom, | ||
paddingLeft, | ||
paddingRight, | ||
} = window.getComputedStyle( element ); | ||
const top = paddingTop ? parseInt( paddingTop, 10 ) : 0; | ||
const bottom = paddingBottom ? parseInt( paddingBottom, 10 ) : 0; | ||
const left = paddingLeft ? parseInt( paddingLeft, 10 ) : 0; | ||
const right = paddingRight ? parseInt( paddingRight, 10 ) : 0; | ||
|
||
return { | ||
x: rect.left + left, | ||
y: rect.top + top, | ||
width: rect.width - left - right, | ||
height: rect.height - top - bottom, | ||
left: rect.left + left, | ||
right: rect.right - right, | ||
top: rect.top + top, | ||
bottom: rect.bottom - bottom, | ||
}; | ||
} | ||
|
||
/** | ||
* Hook used to compute and update the anchor position properly. | ||
* | ||
* @param {Object} anchorRef reference to the popover anchor element. | ||
* @param {Object} contentRef reference to the popover content element. | ||
* @param {Object} anchorRect anchor Rect prop used to override the computed value. | ||
* @param {Function} getAnchorRect function used to override the anchor value computation algorithm. | ||
* @param {Object} anchorRefFallback Reference to the popover anchor fallback element. | ||
* @param {Object} contentRef Reference to the popover content element. | ||
* @param {Object} anchorRect Anchor Rect prop used to override the computed value. | ||
* @param {Function} getAnchorRect Function used to override the anchor value computation algorithm. | ||
* @param {Element|Range} anchorRef A live element or range reference. | ||
* @param {boolean} shouldAnchorIncludePadding Whether to include the anchor padding. | ||
* @param {number} anchorVerticalBuffer Vertical buffer for the anchor. | ||
* @param {number} anchorHorizontalBuffer Horizontal buffer for the anchor. | ||
* | ||
* @return {Object} Anchor position. | ||
*/ | ||
function useAnchor( anchorRef, contentRef, anchorRect, getAnchorRect ) { | ||
function useAnchor( | ||
anchorRefFallback, | ||
contentRef, | ||
anchorRect, | ||
getAnchorRect, | ||
anchorRef, | ||
shouldAnchorIncludePadding, | ||
anchorVerticalBuffer, | ||
anchorHorizontalBuffer | ||
) { | ||
const [ anchor, setAnchor ] = useState( null ); | ||
const refreshAnchorRect = () => { | ||
if ( ! anchorRef.current ) { | ||
return; | ||
} | ||
let newAnchor = computeAnchorRect( | ||
anchorRefFallback, | ||
anchorRect, | ||
getAnchorRect, | ||
anchorRef, | ||
shouldAnchorIncludePadding | ||
); | ||
|
||
let newAnchor; | ||
if ( anchorRect ) { | ||
newAnchor = anchorRect; | ||
} else if ( getAnchorRect ) { | ||
newAnchor = getAnchorRect( anchorRef.current ); | ||
} else { | ||
const rect = anchorRef.current.parentNode.getBoundingClientRect(); | ||
// subtract padding | ||
const { paddingTop, paddingBottom } = window.getComputedStyle( anchorRef.current.parentNode ); | ||
const topPad = parseInt( paddingTop, 10 ); | ||
const bottomPad = parseInt( paddingBottom, 10 ); | ||
newAnchor = { | ||
x: rect.left, | ||
y: rect.top + topPad, | ||
width: rect.width, | ||
height: rect.height - topPad - bottomPad, | ||
left: rect.left, | ||
right: rect.right, | ||
top: rect.top + topPad, | ||
bottom: rect.bottom - bottomPad, | ||
}; | ||
} | ||
newAnchor = addBuffer( | ||
newAnchor, | ||
anchorVerticalBuffer, | ||
anchorHorizontalBuffer | ||
); | ||
|
||
const didAnchorRectChange = ! isShallowEqual( newAnchor, anchor ); | ||
if ( didAnchorRectChange ) { | ||
if ( ! isShallowEqual( newAnchor, anchor ) ) { | ||
setAnchor( newAnchor ); | ||
} | ||
}; | ||
|
@@ -154,11 +241,11 @@ function useInitialContentSize( ref ) { | |
* Hook used to compute and update the position of the popover | ||
* based on the anchor position and the content size. | ||
* | ||
* @param {Object} anchor Anchor Position. | ||
* @param {Object} contentSize Content Size. | ||
* @param {string} position Position prop. | ||
* @param {boolean} expandOnMobile Whether to show the popover full width on mobile. | ||
* @param {Object} contentRef Reference to the popover content element. | ||
* @param {Object} anchor Anchor Position. | ||
* @param {Object} contentSize Content Size. | ||
* @param {string} position Position prop. | ||
* @param {boolean} expandOnMobile Whether to show the popover full width on mobile. | ||
* @param {Object} contentRef Reference to the popover content element. | ||
* | ||
* @return {Object} Popover position. | ||
*/ | ||
|
@@ -206,7 +293,7 @@ function usePopoverPosition( anchor, contentSize, position, expandOnMobile, cont | |
* Hook used to focus the first tabbable element on mount. | ||
* | ||
* @param {boolean|string} focusOnMount Focus on mount mode. | ||
* @param {Object} contentRef Reference to the popover content element. | ||
* @param {Object} contentRef Reference to the popover content element. | ||
*/ | ||
function useFocusContentOnMount( focusOnMount, contentRef ) { | ||
// Focus handling | ||
|
@@ -259,6 +346,10 @@ const Popover = ( { | |
position = 'top', | ||
range, | ||
focusOnMount = 'firstElement', | ||
anchorRef, | ||
shouldAnchorIncludePadding, | ||
anchorVerticalBuffer, | ||
anchorHorizontalBuffer, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you think we should have a single prop instead with different options? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't like nesting that much for component props. That would break shallow comparison, right? Maybe we can name them differently... There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It does break shallow comparison. we can use things like I like the approach of this PR, maybe we can get more thoughts on props naming here @mcsf There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder how other popover libraries or design systems name/handle the positioning options. cc @ItsJonQ There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @youknowriad + @ellatrix 👋 Halloo! A popular popover/positioner library folks use (under the hood) is Popper.js Their React implementation uses the main library and share the same API.
I've used both shallow and nested approaches before. Both have their pros and cons :). Typically component library components/primitives have lots of props. Way more compared to components created at the app level. If this were going into However, since this is updating an "application" component, something that typically won't be consumed externally and used to build interfaces... I would agree with you @ellatrix, and vote for shallow. Hope this helps! <3 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Could you elaborate? What are the pros of nesting? I'm glad to see that There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
❤️ A pro of nesting would be that it keeps related props grouped together. I would only nest 1 level deep (2 max!!! if you really really need to) An example would be react-select. The It's not the end of the world. But that library has a ton of props. I think this keeps it tidier :) That being said.. if one decides to nest, they'll need to do things like (as @youknowriad mentioned) memoize. Also to do some sort of defaultValues + userDefinedValues merging. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤦♂ My apologies! I didn't realize these changes were on
I agree. These primitives should take care of this overhead. If we were to use an
I'm guessing not, since
Given its current state, I think it might be better to keep it simple (and shallow). Apologies for the back and forth! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I mean, the interface provider can always manage itself with The question is probably how much we expect these interfaces to grow, since we aren't building an all-encompassing popover library. My first impression is that it's probably not worth the extra complexity to have nested props.
I think this PR is good. I would just double-check that the props are named in a way that conveys their type well, as pointed out in https://github.com/WordPress/gutenberg/pull/17867/files#r351238110. |
||
anchorRect, | ||
getAnchorRect, | ||
expandOnMobile, | ||
|
@@ -268,14 +359,23 @@ const Popover = ( { | |
/* eslint-enable no-unused-vars */ | ||
...contentProps | ||
} ) => { | ||
const anchorRef = useRef( null ); | ||
const anchorRefFallback = useRef( null ); | ||
const contentRef = useRef( null ); | ||
|
||
// Animation | ||
const [ isReadyToAnimate, setIsReadyToAnimate ] = useState( false ); | ||
|
||
// Anchor position | ||
const anchor = useAnchor( anchorRef, contentRef, anchorRect, getAnchorRect ); | ||
const anchor = useAnchor( | ||
anchorRefFallback, | ||
contentRef, | ||
anchorRect, | ||
getAnchorRect, | ||
anchorRef, | ||
shouldAnchorIncludePadding, | ||
anchorVerticalBuffer, | ||
anchorHorizontalBuffer | ||
); | ||
|
||
// Content size | ||
const contentSize = useInitialContentSize( contentRef ); | ||
|
@@ -438,7 +538,7 @@ const Popover = ( { | |
} | ||
|
||
return ( | ||
<span ref={ anchorRef }> | ||
<span ref={ anchorRefFallback }> | ||
{ content } | ||
{ popoverPosition.isMobile && expandOnMobile && <ScrollLock /> } | ||
</span> | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't see much difference between
computeAnchorRect
anduseAnchor
. I mean we could just inline it here. What's the reasoning?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's clearer to me to make early returns than to wrap inside
else
blocks. :)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And to return a value to a constant instead of assigning it to a variable.