From 8ecb708344d7044cb092f02f9407ca31eea87a88 Mon Sep 17 00:00:00 2001 From: Trevor Gau Date: Wed, 10 Feb 2021 15:23:12 -0500 Subject: [PATCH 01/20] Add behavior and generic hook for anchored positioning. --- src/behaviors/anchoredPosition.ts | 327 ++++++++++++++++++++ src/hooks/useAnchoredPosition.ts | 41 +++ src/hooks/useProvidedRefOrCreate.ts | 14 + src/stories/useAnchoredPosition.stories.tsx | 45 +++ 4 files changed, 427 insertions(+) create mode 100644 src/behaviors/anchoredPosition.ts create mode 100644 src/hooks/useAnchoredPosition.ts create mode 100644 src/hooks/useProvidedRefOrCreate.ts create mode 100644 src/stories/useAnchoredPosition.stories.tsx diff --git a/src/behaviors/anchoredPosition.ts b/src/behaviors/anchoredPosition.ts new file mode 100644 index 00000000000..5d23c464f68 --- /dev/null +++ b/src/behaviors/anchoredPosition.ts @@ -0,0 +1,327 @@ +export type AnchoredPositionAlign = 'first' | 'center' | 'last' + +// When prettier supports template literal types... +// export type SuperSide = `${'inside' | 'outside'}-${'top' | 'bottom' | 'right' | 'left'}` | 'inside-center' +export type AnchorSide = + | 'inside-top' + | 'inside-bottom' + | 'inside-left' + | 'inside-right' + | 'inside-center' + | 'outside-top' + | 'outside-bottom' + | 'outside-left' + | 'outside-right' + +/** + * Settings that customize how a floating element is positioned + * with respect to an anchor element. + */ +export interface PositionSettings { + /** + * Sets the side of the anchor element that the floating element should be + * pinned to. This side is given by a string starting with either "inside" or + * "outside", followed by a hyphen, followed by either "top", "right", "bottom", + * or "left". Additionally, "inside-center" is an allowed value. + * + * The first part of this string, "inside" or "outside", determines whether the + * floating element should be attempted to be placed "inside" the anchor element + * or "outside" of it. Using "inside" is useful for making it appear that the + * anchor _contains_ the floating element, and it can be used for implementing a + * dialog that is centered on the screen. The "outside" value is more common and + * can be used for tooltips, popovers, menus, etc. + * + * The second part of this string determines the _edge_ on the anchor element that + * the floating element will be anchored to. If side is "inside-center", then + * the floating element will be centered in the X-direction (while align is used + * to position it in the Y-direction). + * Note: "outside-center" is _not_ a valid value for this property. + */ + side: AnchorSide + + /** + * Determines how the floating element should align with the anchor element. If + * set to "first", the floating element's first edge (top or left) will align + * with the anchor element's first edge. If set to "center", the floating + * element will be centered along the axis of the anchor edge. If set to "last", + * the floating element's last edge will align with the anchor element's last edge. + */ + align: AnchoredPositionAlign + + /** + * The number of pixels between the anchor edge and the floating element. + * + * Positive values move the floating element farther from the anchor element + * (for outside positioning) or further inside the anchor element (for inside + * positioning). Negative values have the opposite effect. + */ + anchorOffset: number + + /** + * An additional offset, in pixels, to move the floating element from + * the aligning edge. + * + * Positive values move the floating element in the direction of center- + * alignment. Negative values move the floating element away from center- + * alignment. When align is "center", positive offsets move the floating + * element right (top or bottom anchor side) or down (left or right + * anchor side). + */ + alignmentOffset: number + + /** + * If true, when the above settings result in rendering the floating element + * wholly or partially off-screen, attempt to adjust the settings to prevent + * this. Only applies to "outside" positioning. + * + * First, attempt to flip to the opposite edge of the anchor if the floating + * element is getting clipped in that direction. If flipping results in a + * similar clipping, try moving to the adjacent sides. + * + * Once we find a side that does not clip the overlay in its own dimension, + * check the rest of the sides to see if we need to adjust the alignment offset + * to fit in other dimensions. + * + * If we try all four sides and get clipped each time, settle for overflowing + * and use the "bottom" side, since the ability to scroll is most likely in + * this direction. + */ + preventOverflow: boolean +} + +// Default settings to position a floating element +const positionDefaults: PositionSettings = { + side: 'outside-bottom', + align: 'first', + anchorOffset: 8, + alignmentOffset: 0, + preventOverflow: true +} + +// For each outside anchor position, list the order of alternate positions to try in +// the event that the original position overflows. See comment on `preventOverflow` +// for a more detailed description. +const alternateOrders: Partial> = { + 'outside-top': ['outside-bottom', 'outside-right', 'outside-left', 'outside-bottom'], + 'outside-bottom': ['outside-top', 'outside-right', 'outside-left', 'outside-bottom'], + 'outside-left': ['outside-right', 'outside-bottom', 'outside-top', 'outside-bottom'], + 'outside-right': ['outside-left', 'outside-bottom', 'outside-top', 'outside-bottom'] +} + +interface WidthAndHeight { + width: number + height: number +} + +interface TopAndLeft { + top: number + left: number +} + +interface BoxPosition { + top: number + right: number + bottom: number + left: number +} + +/** + * Given a floating element and an anchor element, return coordinates for the + * top-left of the floating element in order to absolutely position it such + * that it appears near the anchor element. + * + * @param elementDimensions Dimensions of the floating element + * @param anchorPosition Position of the anchor element + * @param side Side of the anchor to position the floating element + * @param align How to align the floating element with the anchor element + * @param anchorOffset Absolute pixel offset for anchor positioning + * @param alignmentOffset Absolute pixel offset for alignment + * @returns {top: number, left: number} coordinates for the floating element + */ +function calculatePosition( + elementDimensions: WidthAndHeight, + anchorPosition: BoxPosition, + side: AnchorSide, + align: AnchoredPositionAlign, + anchorOffset: number, + alignmentOffset: number +) { + const anchorWidth = anchorPosition.right - anchorPosition.left + const anchorHeight = anchorPosition.bottom - anchorPosition.top + let top = -1 + let left = -1 + if (side === 'outside-top') { + top = anchorPosition.top - anchorOffset - elementDimensions.height + } else if (side === 'outside-bottom') { + top = anchorPosition.bottom + anchorOffset + } else if (side === 'outside-left') { + left = anchorPosition.left - anchorOffset - elementDimensions.width + } else if (side === 'outside-right') { + left = anchorPosition.right + anchorOffset + } + + if (side === 'outside-top' || side === 'outside-bottom') { + if (align === 'first') { + left = anchorPosition.left + alignmentOffset + } else if (align === 'center') { + left = anchorPosition.left - (elementDimensions.width - anchorWidth) / 2 + alignmentOffset + } else if (align === 'last') { + left = anchorPosition.right - elementDimensions.width - alignmentOffset + } + } + + if (side === 'outside-left' || side === 'outside-right') { + if (align === 'first') { + top = anchorPosition.top + alignmentOffset + } else if (align === 'center') { + top = anchorPosition.top - (elementDimensions.height - anchorHeight) / 2 + alignmentOffset + } else if (align === 'last') { + top = anchorPosition.bottom - elementDimensions.height - alignmentOffset + } + } + + if (side === 'inside-top') { + top = anchorPosition.top + anchorOffset + } else if (side === 'inside-bottom') { + top = anchorPosition.bottom - anchorOffset - elementDimensions.height + } else if (side === 'inside-left') { + left = anchorPosition.left + anchorOffset + } else if (side === 'inside-right') { + left = anchorPosition.right - anchorOffset - elementDimensions.width + } else if (side === 'inside-center') { + left = (anchorPosition.right + anchorPosition.left) / 2 - elementDimensions.width / 2 + anchorOffset + } + + if (side === 'inside-top' || side === 'inside-bottom') { + if (align === 'first') { + left = anchorPosition.left + alignmentOffset + } else if (align === 'center') { + left = anchorPosition.left - (elementDimensions.width - anchorWidth) / 2 + alignmentOffset + } else if (align === 'last') { + left = anchorPosition.right - elementDimensions.width - alignmentOffset + } + } else if (side === 'inside-left' || side === 'inside-right' || side === 'inside-center') { + if (align === 'first') { + top = anchorPosition.top + alignmentOffset + } else if (align === 'center') { + top = anchorPosition.top - (elementDimensions.height - anchorHeight) / 2 + alignmentOffset + } else if (align === 'last') { + top = anchorPosition.bottom - elementDimensions.height - alignmentOffset + } + } + + return {top, left} +} + +/** + * Given a floating element and an anchor element, return coordinates for the top-left + * of the floating element in order to absolutely position it such that it appears + * near the anchor element. + * + * @param floatingElement Element intended to be positioned near or within an anchor + * @param anchorElement The element to serve as the position anchor + * @param settings Settings to determine the rules for positioning the floating element + * @returns {top: number, left: number} coordinates for the floating element + */ +export function getAnchoredPosition( + floatingElement: Element, + anchorElement: Element | DOMRect, + settings: Partial = {} +): {top: number; left: number} { + const side = settings.side ?? positionDefaults.side + const align = settings.align ?? positionDefaults.align + return _getAnchoredPosition( + floatingElement, + anchorElement instanceof Element ? anchorElement.getBoundingClientRect() : anchorElement, + { + side, + align, + // offsets always default to 0 if their respective side/alignment is centered + anchorOffset: settings.anchorOffset ?? (side === 'inside-center' ? 0 : positionDefaults.anchorOffset), + alignmentOffset: settings.alignmentOffset ?? (align === 'center' ? 0 : positionDefaults.alignmentOffset), + preventOverflow: settings.preventOverflow ?? positionDefaults.preventOverflow + } + ) +} + +function shouldRecalculatePosition( + side: AnchorSide, + currentPos: TopAndLeft, + containerDimensions: WidthAndHeight, + elementDimensions: WidthAndHeight +) { + if (side === 'outside-top' || side === 'outside-bottom') { + return currentPos.top < 0 || currentPos.top + elementDimensions.height > containerDimensions.height + } else { + return currentPos.left < 0 || currentPos.left + elementDimensions.width > containerDimensions.width + } +} + +function _getAnchoredPosition( + floatingElement: Element, + anchorRect: DOMRect, + {side, align, preventOverflow, anchorOffset, alignmentOffset}: PositionSettings +): {top: number; left: number} { + const elementRect = floatingElement.getBoundingClientRect() + + let pos = calculatePosition( + elementRect, + {top: anchorRect.top, left: anchorRect.left, right: anchorRect.right, bottom: anchorRect.bottom}, + side, + align, + anchorOffset, + alignmentOffset + ) + + // Handle screen overflow + if (preventOverflow) { + const alternateOrder = alternateOrders[side] + let positionAttempt = 0 + if (alternateOrder) { + let prevSide = side + const containerDimensions = { + // @todo allow custom container dimensions + width: Math.max(document.body.scrollWidth, window.innerWidth), + height: Math.max(document.body.scrollHeight, window.innerHeight) + } + + while ( + positionAttempt < alternateOrder.length && + shouldRecalculatePosition(prevSide, pos, containerDimensions, elementRect) + ) { + const nextSide = alternateOrder[positionAttempt++] + prevSide = nextSide + + // If we have cut off in the same dimension as the "side" option, try flipping to the opposite side. + pos = calculatePosition( + elementRect, + {top: anchorRect.top, left: anchorRect.left, right: anchorRect.right, bottom: anchorRect.bottom}, + nextSide, + align, + anchorOffset, + alignmentOffset + ) + } + } + // At this point we've flipped the position if applicable. Now just nudge until it's on-screen. + if (pos.top < 0) { + pos.top = 0 + } + if (pos.left < 0) { + pos.left = 0 + } + + // If we have exhausted all possible positions and none of them worked, we + // say that overflowing the bottom of the screen is acceptable since it is + // likely to be able to scroll. + if (alternateOrder && positionAttempt < alternateOrder.length) { + if (pos.top + elementRect.height > window.innerHeight) { + pos.top = window.innerHeight - elementRect.height + } + } + if (pos.left + elementRect.width > window.innerWidth) { + pos.left = window.innerWidth - elementRect.width + } + } + return pos +} diff --git a/src/hooks/useAnchoredPosition.ts b/src/hooks/useAnchoredPosition.ts new file mode 100644 index 00000000000..ee1d13a8a53 --- /dev/null +++ b/src/hooks/useAnchoredPosition.ts @@ -0,0 +1,41 @@ +import React from 'react' +import {PositionSettings, getAnchoredPosition} from '../behaviors/anchoredPosition' +import {useProvidedRefOrCreate} from './useProvidedRefOrCreate' + +export interface AnchoredPositionHookSettings extends Partial { + floatingElementRef?: React.RefObject + anchorElementRef?: React.RefObject +} + +/** + * Calculates the top and left values for an absolutely-positioned floating element + * to be anchored to some anchor element. Returns refs for the floating element + * and the anchor element, along with the position. + * @param settings Settings for calculating the anchored position. + * @param dependencies Dependencies to determine when to re-calculate the position. + * @returns An object of {top: number, left: number} to absolutely-position the + * floating element. + */ +export function useAnchoredPosition( + settings?: AnchoredPositionHookSettings, + dependencies?: React.DependencyList +): { + floatingElementRef: React.RefObject + anchorElementRef: React.RefObject + position: {top: number; left: number} | undefined +} { + const floatingElementRef = useProvidedRefOrCreate(settings?.floatingElementRef) + const anchorElementRef = useProvidedRefOrCreate(settings?.anchorElementRef) + const [position, setPosition] = React.useState<{top: number; left: number} | undefined>(undefined) + React.useEffect(() => { + if (floatingElementRef.current instanceof Element && anchorElementRef.current instanceof Element) { + setPosition(getAnchoredPosition(floatingElementRef.current, anchorElementRef.current, settings)) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, dependencies) + return { + floatingElementRef, + anchorElementRef, + position + } +} diff --git a/src/hooks/useProvidedRefOrCreate.ts b/src/hooks/useProvidedRefOrCreate.ts new file mode 100644 index 00000000000..b31a146e163 --- /dev/null +++ b/src/hooks/useProvidedRefOrCreate.ts @@ -0,0 +1,14 @@ +import React from 'react' + +/** + * There are some situations where we only want to create a new ref if one is not provided to a component + * or hook as a prop. However, due to the `rules-of-hooks`, we cannot conditionally make a call to `React.useRef` + * only in the situations where the ref is not provided as a prop. + * This hook aims to encapsulate that logic, so the consumer doesn't need to be concerned with violating `rules-of-hooks`. + * @param providedRef The ref to use - if undefined, will use the ref from a call to React.useRef + * @type TRef The type of the RefObject which should be created. + */ +export function useProvidedRefOrCreate(providedRef?: React.RefObject): React.RefObject { + const createdRef = React.useRef(null) + return providedRef ?? createdRef +} \ No newline at end of file diff --git a/src/stories/useAnchoredPosition.stories.tsx b/src/stories/useAnchoredPosition.stories.tsx new file mode 100644 index 00000000000..975e54663a6 --- /dev/null +++ b/src/stories/useAnchoredPosition.stories.tsx @@ -0,0 +1,45 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import React from 'react' +import {Meta} from '@storybook/react' + +import {BaseStyles, BorderBox, Position} from '..' +import {useAnchoredPosition} from '../hooks/useAnchoredPosition' +import styled from 'styled-components' + +export default { + title: 'Hooks/useAnchoredPosition', + decorators: [ + Story => { + return ( + + + + ) + } + ] +} as Meta + +const BorderedPosition = styled(Position)` + border: 1px solid #ccc; +` + +export const UseAnchoredPosition = () => { + const {floatingElementRef, anchorElementRef, position} = useAnchoredPosition({side: 'outside-bottom', align: 'center'}) + return ( +
+ } + > + Floating element + + }> + Anchor Element + +
+ ) +} From b63fca05a5041e6c6a012b3b0ed65570776a6e87 Mon Sep 17 00:00:00 2001 From: Trevor Gau Date: Thu, 18 Feb 2021 14:46:46 -0500 Subject: [PATCH 02/20] Properly handle positioning an element with a relatively-positioned ancestor. --- src/behaviors/anchoredPosition.ts | 43 +++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/src/behaviors/anchoredPosition.ts b/src/behaviors/anchoredPosition.ts index 5d23c464f68..7673d4d5957 100644 --- a/src/behaviors/anchoredPosition.ts +++ b/src/behaviors/anchoredPosition.ts @@ -257,21 +257,33 @@ function shouldRecalculatePosition( } } +/** + * Returns the nearest proper HTMLElement parent of `element` whose + * position is not "static", or document.body, whichever is closer + */ +function getPositionedParent(element: Element) { + let parentNode = element.parentNode + while (parentNode != undefined) { + if (parentNode instanceof HTMLElement && getComputedStyle(parentNode).position !== "static") { + return parentNode + } + parentNode = parentNode.parentNode + } + return document.body +} + function _getAnchoredPosition( floatingElement: Element, anchorRect: DOMRect, {side, align, preventOverflow, anchorOffset, alignmentOffset}: PositionSettings ): {top: number; left: number} { + const positionedParent = getPositionedParent(floatingElement) + const parentRect = positionedParent.getBoundingClientRect() const elementRect = floatingElement.getBoundingClientRect() - let pos = calculatePosition( - elementRect, - {top: anchorRect.top, left: anchorRect.left, right: anchorRect.right, bottom: anchorRect.bottom}, - side, - align, - anchorOffset, - alignmentOffset - ) + let pos = calculatePosition(elementRect, anchorRect, side, align, anchorOffset, alignmentOffset) + pos.top -= parentRect.top + pos.left -= parentRect.left // Handle screen overflow if (preventOverflow) { @@ -281,8 +293,8 @@ function _getAnchoredPosition( let prevSide = side const containerDimensions = { // @todo allow custom container dimensions - width: Math.max(document.body.scrollWidth, window.innerWidth), - height: Math.max(document.body.scrollHeight, window.innerHeight) + width: parentRect.width, // Math.max(document.body.scrollWidth, window.innerWidth), + height: parentRect.height //Math.max(document.body.scrollHeight, window.innerHeight) } while ( @@ -301,6 +313,8 @@ function _getAnchoredPosition( anchorOffset, alignmentOffset ) + pos.top -= parentRect.top + pos.left -= parentRect.left } } // At this point we've flipped the position if applicable. Now just nudge until it's on-screen. @@ -315,13 +329,14 @@ function _getAnchoredPosition( // say that overflowing the bottom of the screen is acceptable since it is // likely to be able to scroll. if (alternateOrder && positionAttempt < alternateOrder.length) { - if (pos.top + elementRect.height > window.innerHeight) { - pos.top = window.innerHeight - elementRect.height + if (pos.top + elementRect.height > parentRect.height) { + pos.top = parentRect.height - elementRect.height } } - if (pos.left + elementRect.width > window.innerWidth) { - pos.left = window.innerWidth - elementRect.width + if (pos.left + elementRect.width > parentRect.width) { + pos.left = parentRect.width - elementRect.width } } + // Adjust for a positioned parent return pos } From bebd2f5ab178c38c9a908cad6c12cc26c570f539 Mon Sep 17 00:00:00 2001 From: Trevor Gau Date: Wed, 24 Feb 2021 11:00:09 -0500 Subject: [PATCH 03/20] Tests for non-overflow cases. --- jest.config.js | 1 + src/__tests__/behaviors/anchoredPosition.ts | 183 ++++++++++++++++++++ src/behaviors/anchoredPosition.ts | 170 ++++++++++-------- 3 files changed, 284 insertions(+), 70 deletions(-) create mode 100644 src/__tests__/behaviors/anchoredPosition.ts diff --git a/jest.config.js b/jest.config.js index 06526381c1a..5b4331a110d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,3 +1,4 @@ +/* eslint-disable github/unescaped-html-literal */ module.exports = { cacheDirectory: '.test', collectCoverage: true, diff --git a/src/__tests__/behaviors/anchoredPosition.ts b/src/__tests__/behaviors/anchoredPosition.ts new file mode 100644 index 00000000000..362995ca215 --- /dev/null +++ b/src/__tests__/behaviors/anchoredPosition.ts @@ -0,0 +1,183 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import {getAnchoredPosition, PositionSettings} from '../../behaviors/anchoredPosition' + +/* + +Note: In each test below, we check the calculation from getAnchoredPosition against exact +values. For each `expect` call, there is an accompanying comment that distills the effective +calculation from the inputs, which may help debugging in the event of a test failure. + +*/ + +// The DOMRect constructor isn't available in JSDOM, so we improvise here. +function makeDOMRect(x: number, y: number, width: number, height: number): DOMRect { + return { + x, + y, + width, + height, + top: y, + left: x, + right: x + width, + bottom: y + height, + toJSON() { + return this + } + } +} + +// Since Jest/JSDOM doesn't support layout, we can stub out getBoundingClientRect if we know the +// correct dimensions. JSDOM will handle the rest of the DOM API used by getAnchoredPosition. +function createVirtualDOM( + parentRect: DOMRect, + anchorRect: DOMRect, + floatingRect: DOMRect, + parentBorders: {top: number; right: number; bottom: number; left: number} = {top: 0, right: 0, bottom: 0, left: 0} +) { + const parent = document.createElement('div') + parent.style.position = 'relative' + parent.style.borderTopWidth = parentBorders.top + 'px' + parent.style.borderRightWidth = parentBorders.right + 'px' + parent.style.borderBottomWidth = parentBorders.bottom + 'px' + parent.style.borderLeftWidth = parentBorders.left + 'px' + parent.id = 'parent' + parent.innerHTML = `
` + const float = parent.querySelector('#float')! + const anchor = parent.querySelector('#anchor')! + anchor.getBoundingClientRect = () => anchorRect + parent.getBoundingClientRect = () => parentRect + float.getBoundingClientRect = () => floatingRect + return {float, parent, anchor} +} + +describe('getAnchoredPosition', () => { + it('returns the correct position in the default case with no overflow', () => { + const parentElement = makeDOMRect(20, 20, 500, 500) + const anchorElement = makeDOMRect(300, 200, 50, 50) + const floatingElement = makeDOMRect(-9999, -9999, 100, 100) + const {float, anchor} = createVirtualDOM(parentElement, anchorElement, floatingElement) + const settings: Partial = {anchorOffset: 4} + + const {top, left} = getAnchoredPosition(float, anchor, settings) + + expect(top).toEqual(234) + expect(left).toEqual(280) + }) + + it('returns the correct position for different outside side settings with no overflow', () => { + const parentElement = makeDOMRect(20, 20, 500, 500) + const anchorElement = makeDOMRect(300, 200, 50, 50) + const floatingElement = makeDOMRect(-9999, -9999, 100, 100) + const {float, anchor} = createVirtualDOM(parentElement, anchorElement, floatingElement) + const settings: Partial = {} + let top = 0 + let left = 0 + + // should be the same calculation as the default settings test above + settings.side = 'outside-bottom' + ;({top, left} = getAnchoredPosition(float, anchor, settings)) + expect(top).toEqual(234) // anchorElement.top + anchorElement.height + (settings.anchorOffset ?? 4) - parentElement.top + expect(left).toEqual(280) // anchorElement.left - parentElement.left + + settings.side = 'outside-left' + ;({top, left} = getAnchoredPosition(float, anchor, settings)) + expect(top).toEqual(180) // anchorElement.top - parentElement.top + expect(left).toEqual(176) // anchorElement.left - floatingElement.width - (settings.anchorOffset ?? 4) - parentElement.left + + settings.side = 'outside-right' + ;({top, left} = getAnchoredPosition(float, anchor, settings)) + expect(top).toEqual(180) // anchorElement.top - parentElement.top + expect(left).toEqual(334) // anchorElement.left + anchorElement.width + (settings.anchorOffset ?? 4) - parentElement.left + + settings.side = 'outside-top' + ;({top, left} = getAnchoredPosition(float, anchor, settings)) + expect(top).toEqual(76) // anchorElement.top - floatingElement.height - (settings.anchorOffset ?? 4) - parentElement.top + expect(left).toEqual(280) // anchorElement.left - parentElement.left + }) + + it('returns the correct position for different inside side settings', () => { + const parentElement = makeDOMRect(20, 20, 500, 500) + const anchorElement = makeDOMRect(300, 200, 50, 50) + const floatingElement = makeDOMRect(-9999, -9999, 100, 100) + const {float, anchor} = createVirtualDOM(parentElement, anchorElement, floatingElement) + const settings: Partial = {} + let top = 0 + let left = 0 + + settings.side = 'inside-bottom' + ;({top, left} = getAnchoredPosition(float, anchor, settings)) + + // anchorElement.top + anchorElement.height - (settings.anchorOffset ?? 4) - floatingElement.height - parentElement.top + expect(top).toEqual(126) + // anchorElement.left + (settings.alignmentOffset ?? 4) - parentElement.left + expect(left).toEqual(284) + + settings.side = 'inside-left' + ;({top, left} = getAnchoredPosition(float, anchor, settings)) + expect(top).toEqual(184) // anchorElement.top + (settings.alignmentOffset ?? 4) - parentElement.top + expect(left).toEqual(284) // anchorElement.left + (settings.anchorOffset ?? 4) - parentElement.left + + settings.side = 'inside-right' + ;({top, left} = getAnchoredPosition(float, anchor, settings)) + + // anchorElement.top + (settings.alignmentOffset ?? 4) - parentElement.top + expect(top).toEqual(184) + // anchorElement.left + anchorElement.width - (settings.anchorOffset ?? 4) - floatingElement.width - parentElement.left + expect(left).toEqual(226) + + // almost the same as inside-left, with the exception of offsets + settings.side = 'inside-top' + ;({top, left} = getAnchoredPosition(float, anchor, settings)) + expect(top).toEqual(184) // anchorElement.top + (settings.anchorOffset ?? 4) - parentElement.top + expect(left).toEqual(284) // anchorElement.left + (settings.alignmentOffset ?? 4) - parentElement.left + + settings.side = 'inside-center' + ;({top, left} = getAnchoredPosition(float, anchor, settings)) + expect(top).toEqual(184) // anchorElement.top + (settings.alignmentOffset ?? 4) - parentElement.top + expect(left).toEqual(255) // anchorElement.left + anchorElement.width / 2 - floatingElement.width / 2 - parentElement.left + }) + + it('returns the correct position for different alignment settings with no overflow', () => { + const parentElement = makeDOMRect(20, 20, 500, 500) + const anchorElement = makeDOMRect(300, 200, 50, 50) + const floatingElement = makeDOMRect(-9999, -9999, 100, 100) + const {float, anchor} = createVirtualDOM(parentElement, anchorElement, floatingElement) + const settings: Partial = {} + let top = 0 + let left = 0 + + settings.align = 'first' + ;({top, left} = getAnchoredPosition(float, anchor, settings)) + expect(top).toEqual(234) // anchorElement.top + anchorElement.height + (settings.anchorOffset ?? 4) - parentElement.top + expect(left).toEqual(280) // anchorElement.left + (settings.alignmentOffset ?? 0) - parentElement.left + + settings.align = 'center' + ;({top, left} = getAnchoredPosition(float, anchor, settings)) + + // anchorElement.top + anchorElement.height + (settings.anchorOffset ?? 4) - parentElement.top + expect(top).toEqual(234) + // anchorElement.left + anchorElement.width / 2 - floatingElement.width / 2 + (settings.anchorOffset ?? 0) - parentElement.left + expect(left).toEqual(255) + + settings.align = 'last' + ;({top, left} = getAnchoredPosition(float, anchor, settings)) + + // anchorElement.top + anchorElement.height + (settings.anchorOffset ?? 4) - parentElement.top + expect(top).toEqual(234) + // anchorElement.left + anchorElement.width - floatingElement.width - (settings.alignmentOffset ?? 0) - parentElement.left + expect(left).toEqual(230) + }) + + it('properly flips to the opposite side if the calculated position overflows along the same axis', () => { + const parentElement = makeDOMRect(20, 20, 500, 500) + const anchorElement = makeDOMRect(300, 400, 50, 50) + const floatingElement = makeDOMRect(-9999, -9999, 100, 100) + const {float, anchor} = createVirtualDOM(parentElement, anchorElement, floatingElement) + const settings: Partial = {} + + const {top, left} = getAnchoredPosition(float, anchor, settings) + + expect(top).toEqual(anchorElement.top - floatingElement.height - (settings.anchorOffset ?? 4) - parentElement.top) // anchorElement.top - floatingElement.height - (settings.anchorOffset ?? 4) - parentElement.top + expect(left).toEqual(anchorElement.left - parentElement.left) // anchorElement.left - parentElement.left + }) +}) diff --git a/src/behaviors/anchoredPosition.ts b/src/behaviors/anchoredPosition.ts index 7673d4d5957..0d3569676ef 100644 --- a/src/behaviors/anchoredPosition.ts +++ b/src/behaviors/anchoredPosition.ts @@ -89,15 +89,6 @@ export interface PositionSettings { preventOverflow: boolean } -// Default settings to position a floating element -const positionDefaults: PositionSettings = { - side: 'outside-bottom', - align: 'first', - anchorOffset: 8, - alignmentOffset: 0, - preventOverflow: true -} - // For each outside anchor position, list the order of alternate positions to try in // the event that the original position overflows. See comment on `preventOverflow` // for a more detailed description. @@ -108,22 +99,17 @@ const alternateOrders: Partial = {} ): {top: number; left: number} { - const side = settings.side ?? positionDefaults.side - const align = settings.align ?? positionDefaults.align - return _getAnchoredPosition( - floatingElement, + const parentElement = getPositionedParent(floatingElement) + const parentRect = parentElement.getBoundingClientRect() + const parentStyle = getComputedStyle(parentElement) + const [parentTop, parentLeft, parentRight, parentBottom] = [ + parentStyle.borderTopWidth, + parentStyle.borderLeftWidth, + parentStyle.borderRightWidth, + parentStyle.borderBottomWidth + ].map(v => parseInt(v, 10) || 0) + const parentViewport = { + top: parentRect.top + parentTop, + left: parentRect.left + parentLeft, + width: parentRect.width - parentLeft - parentRight, + height: parentRect.height - parentTop - parentBottom + } as BoxPosition + const _settings = getDefaultSettings(settings) + return pureCalculateAnchoredPosition( + parentViewport, + floatingElement.getBoundingClientRect(), anchorElement instanceof Element ? anchorElement.getBoundingClientRect() : anchorElement, - { - side, - align, - // offsets always default to 0 if their respective side/alignment is centered - anchorOffset: settings.anchorOffset ?? (side === 'inside-center' ? 0 : positionDefaults.anchorOffset), - alignmentOffset: settings.alignmentOffset ?? (align === 'center' ? 0 : positionDefaults.alignmentOffset), - preventOverflow: settings.preventOverflow ?? positionDefaults.preventOverflow - } + _settings ) } function shouldRecalculatePosition( side: AnchorSide, - currentPos: TopAndLeft, - containerDimensions: WidthAndHeight, - elementDimensions: WidthAndHeight + currentPos: Position, + containerDimensions: Size, + elementDimensions: Size ) { if (side === 'outside-top' || side === 'outside-bottom') { return currentPos.top < 0 || currentPos.top + elementDimensions.height > containerDimensions.height @@ -264,7 +258,7 @@ function shouldRecalculatePosition( function getPositionedParent(element: Element) { let parentNode = element.parentNode while (parentNode != undefined) { - if (parentNode instanceof HTMLElement && getComputedStyle(parentNode).position !== "static") { + if (parentNode instanceof HTMLElement && getComputedStyle(parentNode).position !== 'static') { return parentNode } parentNode = parentNode.parentNode @@ -272,16 +266,59 @@ function getPositionedParent(element: Element) { return document.body } -function _getAnchoredPosition( - floatingElement: Element, - anchorRect: DOMRect, +// Default settings to position a floating element +const positionDefaults: PositionSettings = { + side: 'outside-bottom', + align: 'first', + + // note: the following default is not applied if side === "inside-center" + anchorOffset: 4, + + // note: the following default is only applied if side starts with "inside" + // and align is not center + alignmentOffset: 4, + + preventOverflow: true +} + +/** + * Compute a full PositionSettings object from the given partial PositionSettings object + * by filling in with defaults where applicable. + * @param settings Partial settings - any omissions will be defaulted + */ +function getDefaultSettings(settings: Partial = {}): PositionSettings { + const side = settings.side ?? positionDefaults.side + const align = settings.align ?? positionDefaults.align + return { + side, + align, + // offsets always default to 0 if their respective side/alignment is centered + anchorOffset: settings.anchorOffset ?? (side === 'inside-center' ? 0 : positionDefaults.anchorOffset), + alignmentOffset: + settings.alignmentOffset ?? + (align !== 'center' && side.startsWith('inside') ? positionDefaults.alignmentOffset : 0), + preventOverflow: settings.preventOverflow ?? positionDefaults.preventOverflow + } +} + +/** + * Note: This is a pure function with no dependency on DOM APIs (other than DOMRect). Do not + * use this function unless you need a DOM-free, low-level implementaiton. Instead, use + * `getAnchoredPosition`. Position settings not defaulted. + * @see getAnchoredPosition + * @see getDefaultSettings + * @param parentRect BoxPosition for the closest positioned proper parent of the floating element + * @param floatingRect WidthAndHeight for the floating element + * @param anchorRect BoxPosition for the anchor element + * @param PositionSettings to customize the calculated position for the floating element. + */ +function pureCalculateAnchoredPosition( + parentRect: BoxPosition, + floatingRect: Size, + anchorRect: BoxPosition, {side, align, preventOverflow, anchorOffset, alignmentOffset}: PositionSettings ): {top: number; left: number} { - const positionedParent = getPositionedParent(floatingElement) - const parentRect = positionedParent.getBoundingClientRect() - const elementRect = floatingElement.getBoundingClientRect() - - let pos = calculatePosition(elementRect, anchorRect, side, align, anchorOffset, alignmentOffset) + let pos = calculatePosition(floatingRect, anchorRect, side, align, anchorOffset, alignmentOffset) pos.top -= parentRect.top pos.left -= parentRect.left @@ -299,20 +336,13 @@ function _getAnchoredPosition( while ( positionAttempt < alternateOrder.length && - shouldRecalculatePosition(prevSide, pos, containerDimensions, elementRect) + shouldRecalculatePosition(prevSide, pos, containerDimensions, floatingRect) ) { const nextSide = alternateOrder[positionAttempt++] prevSide = nextSide // If we have cut off in the same dimension as the "side" option, try flipping to the opposite side. - pos = calculatePosition( - elementRect, - {top: anchorRect.top, left: anchorRect.left, right: anchorRect.right, bottom: anchorRect.bottom}, - nextSide, - align, - anchorOffset, - alignmentOffset - ) + pos = calculatePosition(floatingRect, anchorRect, nextSide, align, anchorOffset, alignmentOffset) pos.top -= parentRect.top pos.left -= parentRect.left } @@ -329,12 +359,12 @@ function _getAnchoredPosition( // say that overflowing the bottom of the screen is acceptable since it is // likely to be able to scroll. if (alternateOrder && positionAttempt < alternateOrder.length) { - if (pos.top + elementRect.height > parentRect.height) { - pos.top = parentRect.height - elementRect.height + if (pos.top + floatingRect.height > parentRect.height) { + pos.top = parentRect.height - floatingRect.height } } - if (pos.left + elementRect.width > parentRect.width) { - pos.left = parentRect.width - elementRect.width + if (pos.left + floatingRect.width > parentRect.width) { + pos.left = parentRect.width - floatingRect.width } } // Adjust for a positioned parent From e368cd76458916a27c1e13a0d1cc95eb8c6526ae Mon Sep 17 00:00:00 2001 From: Trevor Gau Date: Wed, 24 Feb 2021 15:10:34 -0500 Subject: [PATCH 04/20] Finish anchored position tests and clean up code. --- src/__tests__/behaviors/anchoredPosition.ts | 188 ++++++++++++---- src/behaviors/anchoredPosition.ts | 234 ++++++++++---------- 2 files changed, 260 insertions(+), 162 deletions(-) diff --git a/src/__tests__/behaviors/anchoredPosition.ts b/src/__tests__/behaviors/anchoredPosition.ts index 362995ca215..f72431d49a9 100644 --- a/src/__tests__/behaviors/anchoredPosition.ts +++ b/src/__tests__/behaviors/anchoredPosition.ts @@ -52,10 +52,10 @@ function createVirtualDOM( describe('getAnchoredPosition', () => { it('returns the correct position in the default case with no overflow', () => { - const parentElement = makeDOMRect(20, 20, 500, 500) - const anchorElement = makeDOMRect(300, 200, 50, 50) - const floatingElement = makeDOMRect(-9999, -9999, 100, 100) - const {float, anchor} = createVirtualDOM(parentElement, anchorElement, floatingElement) + const parentRect = makeDOMRect(20, 20, 500, 500) + const anchorRect = makeDOMRect(300, 200, 50, 50) + const floatingRect = makeDOMRect(NaN, NaN, 100, 100) + const {float, anchor} = createVirtualDOM(parentRect, anchorRect, floatingRect) const settings: Partial = {anchorOffset: 4} const {top, left} = getAnchoredPosition(float, anchor, settings) @@ -65,10 +65,10 @@ describe('getAnchoredPosition', () => { }) it('returns the correct position for different outside side settings with no overflow', () => { - const parentElement = makeDOMRect(20, 20, 500, 500) - const anchorElement = makeDOMRect(300, 200, 50, 50) - const floatingElement = makeDOMRect(-9999, -9999, 100, 100) - const {float, anchor} = createVirtualDOM(parentElement, anchorElement, floatingElement) + const parentRect = makeDOMRect(20, 20, 500, 500) + const anchorRect = makeDOMRect(300, 200, 50, 50) + const floatingRect = makeDOMRect(NaN, NaN, 100, 100) + const {float, anchor} = createVirtualDOM(parentRect, anchorRect, floatingRect) const settings: Partial = {} let top = 0 let left = 0 @@ -76,30 +76,30 @@ describe('getAnchoredPosition', () => { // should be the same calculation as the default settings test above settings.side = 'outside-bottom' ;({top, left} = getAnchoredPosition(float, anchor, settings)) - expect(top).toEqual(234) // anchorElement.top + anchorElement.height + (settings.anchorOffset ?? 4) - parentElement.top - expect(left).toEqual(280) // anchorElement.left - parentElement.left + expect(top).toEqual(234) // anchorRect.top + anchorRect.height + (settings.anchorOffset ?? 4) - parentRect.top + expect(left).toEqual(280) // anchorRect.left - parentRect.left settings.side = 'outside-left' ;({top, left} = getAnchoredPosition(float, anchor, settings)) - expect(top).toEqual(180) // anchorElement.top - parentElement.top - expect(left).toEqual(176) // anchorElement.left - floatingElement.width - (settings.anchorOffset ?? 4) - parentElement.left + expect(top).toEqual(180) // anchorRect.top - parentRect.top + expect(left).toEqual(176) // anchorRect.left - floatingRect.width - (settings.anchorOffset ?? 4) - parentRect.left settings.side = 'outside-right' ;({top, left} = getAnchoredPosition(float, anchor, settings)) - expect(top).toEqual(180) // anchorElement.top - parentElement.top - expect(left).toEqual(334) // anchorElement.left + anchorElement.width + (settings.anchorOffset ?? 4) - parentElement.left + expect(top).toEqual(180) // anchorRect.top - parentRect.top + expect(left).toEqual(334) // anchorRect.left + anchorRect.width + (settings.anchorOffset ?? 4) - parentRect.left settings.side = 'outside-top' ;({top, left} = getAnchoredPosition(float, anchor, settings)) - expect(top).toEqual(76) // anchorElement.top - floatingElement.height - (settings.anchorOffset ?? 4) - parentElement.top - expect(left).toEqual(280) // anchorElement.left - parentElement.left + expect(top).toEqual(76) // anchorRect.top - floatingRect.height - (settings.anchorOffset ?? 4) - parentRect.top + expect(left).toEqual(280) // anchorRect.left - parentRect.left }) it('returns the correct position for different inside side settings', () => { - const parentElement = makeDOMRect(20, 20, 500, 500) - const anchorElement = makeDOMRect(300, 200, 50, 50) - const floatingElement = makeDOMRect(-9999, -9999, 100, 100) - const {float, anchor} = createVirtualDOM(parentElement, anchorElement, floatingElement) + const parentRect = makeDOMRect(20, 20, 500, 500) + const anchorRect = makeDOMRect(300, 200, 50, 50) + const floatingRect = makeDOMRect(NaN, NaN, 100, 100) + const {float, anchor} = createVirtualDOM(parentRect, anchorRect, floatingRect) const settings: Partial = {} let top = 0 let left = 0 @@ -107,77 +107,171 @@ describe('getAnchoredPosition', () => { settings.side = 'inside-bottom' ;({top, left} = getAnchoredPosition(float, anchor, settings)) - // anchorElement.top + anchorElement.height - (settings.anchorOffset ?? 4) - floatingElement.height - parentElement.top + // anchorRect.top + anchorRect.height - (settings.anchorOffset ?? 4) - floatingRect.height - parentRect.top expect(top).toEqual(126) - // anchorElement.left + (settings.alignmentOffset ?? 4) - parentElement.left + // anchorRect.left + (settings.alignmentOffset ?? 4) - parentRect.left expect(left).toEqual(284) settings.side = 'inside-left' ;({top, left} = getAnchoredPosition(float, anchor, settings)) - expect(top).toEqual(184) // anchorElement.top + (settings.alignmentOffset ?? 4) - parentElement.top - expect(left).toEqual(284) // anchorElement.left + (settings.anchorOffset ?? 4) - parentElement.left + expect(top).toEqual(184) // anchorRect.top + (settings.alignmentOffset ?? 4) - parentRect.top + expect(left).toEqual(284) // anchorRect.left + (settings.anchorOffset ?? 4) - parentRect.left settings.side = 'inside-right' ;({top, left} = getAnchoredPosition(float, anchor, settings)) - // anchorElement.top + (settings.alignmentOffset ?? 4) - parentElement.top + // anchorRect.top + (settings.alignmentOffset ?? 4) - parentRect.top expect(top).toEqual(184) - // anchorElement.left + anchorElement.width - (settings.anchorOffset ?? 4) - floatingElement.width - parentElement.left + // anchorRect.left + anchorRect.width - (settings.anchorOffset ?? 4) - floatingRect.width - parentRect.left expect(left).toEqual(226) // almost the same as inside-left, with the exception of offsets settings.side = 'inside-top' ;({top, left} = getAnchoredPosition(float, anchor, settings)) - expect(top).toEqual(184) // anchorElement.top + (settings.anchorOffset ?? 4) - parentElement.top - expect(left).toEqual(284) // anchorElement.left + (settings.alignmentOffset ?? 4) - parentElement.left + expect(top).toEqual(184) // anchorRect.top + (settings.anchorOffset ?? 4) - parentRect.top + expect(left).toEqual(284) // anchorRect.left + (settings.alignmentOffset ?? 4) - parentRect.left settings.side = 'inside-center' ;({top, left} = getAnchoredPosition(float, anchor, settings)) - expect(top).toEqual(184) // anchorElement.top + (settings.alignmentOffset ?? 4) - parentElement.top - expect(left).toEqual(255) // anchorElement.left + anchorElement.width / 2 - floatingElement.width / 2 - parentElement.left + expect(top).toEqual(184) // anchorRect.top + (settings.alignmentOffset ?? 4) - parentRect.top + expect(left).toEqual(255) // anchorRect.left + anchorRect.width / 2 - floatingRect.width / 2 - parentRect.left + }) + + it('returns the correct position inside centering along both axes', () => { + const parentRect = makeDOMRect(20, 20, 500, 500) + const anchorRect = makeDOMRect(300, 200, 50, 50) + const floatingRect = makeDOMRect(NaN, NaN, 100, 100) + const {float, anchor} = createVirtualDOM(parentRect, anchorRect, floatingRect) + const settings: Partial = {side: 'inside-center', align: 'center'} + + const {top, left} = getAnchoredPosition(float, anchor, settings) + + expect(top).toEqual(155) // anchorRect.top + anchorRect.height / 2 - floatingRect.height / 2 - parentRect.top + expect(left).toEqual(255) // anchorRect.left + anchorRect.width / 2 - floatingRect.width / 2 - parentRect.left }) it('returns the correct position for different alignment settings with no overflow', () => { - const parentElement = makeDOMRect(20, 20, 500, 500) - const anchorElement = makeDOMRect(300, 200, 50, 50) - const floatingElement = makeDOMRect(-9999, -9999, 100, 100) - const {float, anchor} = createVirtualDOM(parentElement, anchorElement, floatingElement) + const parentRect = makeDOMRect(20, 20, 500, 500) + const anchorRect = makeDOMRect(300, 200, 50, 50) + const floatingRect = makeDOMRect(NaN, NaN, 100, 100) + const {float, anchor} = createVirtualDOM(parentRect, anchorRect, floatingRect) const settings: Partial = {} let top = 0 let left = 0 settings.align = 'first' ;({top, left} = getAnchoredPosition(float, anchor, settings)) - expect(top).toEqual(234) // anchorElement.top + anchorElement.height + (settings.anchorOffset ?? 4) - parentElement.top - expect(left).toEqual(280) // anchorElement.left + (settings.alignmentOffset ?? 0) - parentElement.left + expect(top).toEqual(234) // anchorRect.top + anchorRect.height + (settings.anchorOffset ?? 4) - parentRect.top + expect(left).toEqual(280) // anchorRect.left + (settings.alignmentOffset ?? 0) - parentRect.left settings.align = 'center' ;({top, left} = getAnchoredPosition(float, anchor, settings)) - // anchorElement.top + anchorElement.height + (settings.anchorOffset ?? 4) - parentElement.top + // anchorRect.top + anchorRect.height + (settings.anchorOffset ?? 4) - parentRect.top expect(top).toEqual(234) - // anchorElement.left + anchorElement.width / 2 - floatingElement.width / 2 + (settings.anchorOffset ?? 0) - parentElement.left + // anchorRect.left + anchorRect.width / 2 - floatingRect.width / 2 + (settings.anchorOffset ?? 0) - parentRect.left expect(left).toEqual(255) settings.align = 'last' ;({top, left} = getAnchoredPosition(float, anchor, settings)) - // anchorElement.top + anchorElement.height + (settings.anchorOffset ?? 4) - parentElement.top + // anchorRect.top + anchorRect.height + (settings.anchorOffset ?? 4) - parentRect.top expect(top).toEqual(234) - // anchorElement.left + anchorElement.width - floatingElement.width - (settings.alignmentOffset ?? 0) - parentElement.left + // anchorRect.left + anchorRect.width - floatingRect.width - (settings.alignmentOffset ?? 0) - parentRect.left expect(left).toEqual(230) }) it('properly flips to the opposite side if the calculated position overflows along the same axis', () => { - const parentElement = makeDOMRect(20, 20, 500, 500) - const anchorElement = makeDOMRect(300, 400, 50, 50) - const floatingElement = makeDOMRect(-9999, -9999, 100, 100) - const {float, anchor} = createVirtualDOM(parentElement, anchorElement, floatingElement) + const parentRect = makeDOMRect(20, 20, 500, 500) + const anchorRect = makeDOMRect(300, 400, 50, 50) + const floatingRect = makeDOMRect(NaN, NaN, 100, 100) + const {float, anchor} = createVirtualDOM(parentRect, anchorRect, floatingRect) + const settings: Partial = {} + + const {top, left} = getAnchoredPosition(float, anchor, settings) + + expect(top).toEqual(276) // anchorRect.top - floatingRect.height - (settings.anchorOffset ?? 4) - parentRect.top + expect(left).toEqual(280) // anchorRect.left - parentRect.left + }) + + it('properly moves to an adjacent side if overflow happens along side edge and flipped edge', () => { + const parentRect = makeDOMRect(20, 20, 500, 200) + const anchorRect = makeDOMRect(300, 100, 50, 50) + const floatingRect = makeDOMRect(NaN, NaN, 100, 100) + const {float, anchor} = createVirtualDOM(parentRect, anchorRect, floatingRect) + const settings: Partial = {} + + const {top, left} = getAnchoredPosition(float, anchor, settings) + + expect(top).toEqual(80) // anchorRect.top - parentRect.top + expect(left).toEqual(334) // anchorRect.left + anchorRect.width + (settings.anchorOffset ?? 4) - parentRect.left + }) + + it('properly adjusts the position using an alignment offset if overflow happens along the alignment edge', () => { + const parentRect = makeDOMRect(20, 20, 500, 500) + const anchorRect = makeDOMRect(300, 200, 50, 50) + const floatingRect = makeDOMRect(NaN, NaN, 400, 100) + const {float, anchor} = createVirtualDOM(parentRect, anchorRect, floatingRect) + const settings: Partial = {} + + const {top, left} = getAnchoredPosition(float, anchor, settings) + + expect(top).toEqual(234) // anchorRect.top + anchorRect.height + (settings.anchorOffset ?? 4) - parentRect.top + expect(left).toEqual(100) // parentRect.width - floatingRect.width + }) + + it('properly calculates the position that needs to be flipped and offset-adjusted', () => { + const parentRect = makeDOMRect(20, 20, 500, 500) + const anchorRect = makeDOMRect(300, 400, 50, 50) + const floatingRect = makeDOMRect(NaN, NaN, 400, 100) + const {float, anchor} = createVirtualDOM(parentRect, anchorRect, floatingRect) const settings: Partial = {} - + const {top, left} = getAnchoredPosition(float, anchor, settings) - expect(top).toEqual(anchorElement.top - floatingElement.height - (settings.anchorOffset ?? 4) - parentElement.top) // anchorElement.top - floatingElement.height - (settings.anchorOffset ?? 4) - parentElement.top - expect(left).toEqual(anchorElement.left - parentElement.left) // anchorElement.left - parentElement.left + expect(top).toEqual(276) // anchorRect.top - floatingRect.height - (settings.anchorOffset ?? 4) - parentRect.top + expect(left).toEqual(100) // parentRect.width - floatingRect.width + }) + + it('properly calculates the outside position with many simultaneous settings interactions (stress test)', () => { + const parentRect = makeDOMRect(20, 20, 200, 500) + const anchorRect = makeDOMRect(95, 295, 100, 200) + const floatingRect = makeDOMRect(NaN, NaN, 175, 200) + const {float, anchor} = createVirtualDOM(parentRect, anchorRect, floatingRect) + const settings: Partial = { + side: 'outside-right', + align: 'center', + alignmentOffset: 10, + anchorOffset: -10 + } + + const {top, left} = getAnchoredPosition(float, anchor, settings) + + // expect to try right, left, and bottom before ending on top + expect(top).toEqual(85) // anchorRect.top - floatingRect.height - (settings.anchorOffset ?? 4) - parentRect.top + + // expect center alignment to run against edge, so ignored. Also causes alignment offset to be ignored. + expect(left).toEqual(25) // parentRect.width - floatingRect.width + }) + + it('properly calculates the inside position with many simultaneous settings interactions (stress test)', () => { + const parentRect = makeDOMRect(20, 20, 500, 500) + const anchorRect = makeDOMRect(100, 100, 300, 300) + const floatingRect = makeDOMRect(NaN, NaN, 100, 200) + const {float, anchor} = createVirtualDOM(parentRect, anchorRect, floatingRect) + const settings: Partial = { + side: 'inside-right', + align: 'center', + alignmentOffset: 10, + anchorOffset: -10 + } + + const {top, left} = getAnchoredPosition(float, anchor, settings) + + // anchorRect.top + anchorRect.height / 2 - floatingRect.height / 2 + (settings.alignmentOffset ?? 4) - parentRect.top + expect(top).toEqual(140) + + // anchorRect.left + anchorRect.width - floatingRect.width - (settings.anchorOffset ?? 4) - parentRect.left + expect(left).toEqual(290) }) }) diff --git a/src/behaviors/anchoredPosition.ts b/src/behaviors/anchoredPosition.ts index 0d3569676ef..80538ff9a38 100644 --- a/src/behaviors/anchoredPosition.ts +++ b/src/behaviors/anchoredPosition.ts @@ -1,7 +1,7 @@ export type AnchoredPositionAlign = 'first' | 'center' | 'last' // When prettier supports template literal types... -// export type SuperSide = `${'inside' | 'outside'}-${'top' | 'bottom' | 'right' | 'left'}` | 'inside-center' +// export type AnchorSide = `${'inside' | 'outside'}-${'top' | 'bottom' | 'right' | 'left'}` | 'inside-center' export type AnchorSide = | 'inside-top' | 'inside-bottom' @@ -111,94 +111,6 @@ interface Position { interface BoxPosition extends Size, Position {} -/** - * Given a floating element and an anchor element, return coordinates for the - * top-left of the floating element in order to absolutely position it such - * that it appears near the anchor element. - * - * @param elementDimensions Dimensions of the floating element - * @param anchorPosition Position of the anchor element - * @param side Side of the anchor to position the floating element - * @param align How to align the floating element with the anchor element - * @param anchorOffset Absolute pixel offset for anchor positioning - * @param alignmentOffset Absolute pixel offset for alignment - * @returns {top: number, left: number} coordinates for the floating element - */ -function calculatePosition( - elementDimensions: Size, - anchorPosition: BoxPosition, - side: AnchorSide, - align: AnchoredPositionAlign, - anchorOffset: number, - alignmentOffset: number -) { - const anchorRight = anchorPosition.left + anchorPosition.width - const anchorBottom = anchorPosition.top + anchorPosition.height - let top = -1 - let left = -1 - if (side === 'outside-top') { - top = anchorPosition.top - anchorOffset - elementDimensions.height - } else if (side === 'outside-bottom') { - top = anchorBottom + anchorOffset - } else if (side === 'outside-left') { - left = anchorPosition.left - anchorOffset - elementDimensions.width - } else if (side === 'outside-right') { - left = anchorRight + anchorOffset - } - - if (side === 'outside-top' || side === 'outside-bottom') { - if (align === 'first') { - left = anchorPosition.left + alignmentOffset - } else if (align === 'center') { - left = anchorPosition.left - (elementDimensions.width - anchorPosition.width) / 2 + alignmentOffset - } else if (align === 'last') { - left = anchorRight - elementDimensions.width - alignmentOffset - } - } - - if (side === 'outside-left' || side === 'outside-right') { - if (align === 'first') { - top = anchorPosition.top + alignmentOffset - } else if (align === 'center') { - top = anchorPosition.top - (elementDimensions.height - anchorPosition.height) / 2 + alignmentOffset - } else if (align === 'last') { - top = anchorBottom - elementDimensions.height - alignmentOffset - } - } - - if (side === 'inside-top') { - top = anchorPosition.top + anchorOffset - } else if (side === 'inside-bottom') { - top = anchorBottom - anchorOffset - elementDimensions.height - } else if (side === 'inside-left') { - left = anchorPosition.left + anchorOffset - } else if (side === 'inside-right') { - left = anchorRight - anchorOffset - elementDimensions.width - } else if (side === 'inside-center') { - left = (anchorRight + anchorPosition.left) / 2 - elementDimensions.width / 2 + anchorOffset - } - - if (side === 'inside-top' || side === 'inside-bottom') { - if (align === 'first') { - left = anchorPosition.left + alignmentOffset - } else if (align === 'center') { - left = anchorPosition.left - (elementDimensions.width - anchorPosition.width) / 2 + alignmentOffset - } else if (align === 'last') { - left = anchorRight - elementDimensions.width - alignmentOffset - } - } else if (side === 'inside-left' || side === 'inside-right' || side === 'inside-center') { - if (align === 'first') { - top = anchorPosition.top + alignmentOffset - } else if (align === 'center') { - top = anchorPosition.top - (elementDimensions.height - anchorPosition.height) / 2 + alignmentOffset - } else if (align === 'last') { - top = anchorBottom - elementDimensions.height - alignmentOffset - } - } - - return {top, left} -} - /** * Given a floating element and an anchor element, return coordinates for the top-left * of the floating element in order to absolutely position it such that it appears @@ -223,34 +135,22 @@ export function getAnchoredPosition( parentStyle.borderRightWidth, parentStyle.borderBottomWidth ].map(v => parseInt(v, 10) || 0) + const parentViewport = { top: parentRect.top + parentTop, left: parentRect.left + parentLeft, width: parentRect.width - parentLeft - parentRight, height: parentRect.height - parentTop - parentBottom } as BoxPosition - const _settings = getDefaultSettings(settings) + return pureCalculateAnchoredPosition( parentViewport, floatingElement.getBoundingClientRect(), anchorElement instanceof Element ? anchorElement.getBoundingClientRect() : anchorElement, - _settings + getDefaultSettings(settings) ) } -function shouldRecalculatePosition( - side: AnchorSide, - currentPos: Position, - containerDimensions: Size, - elementDimensions: Size -) { - if (side === 'outside-top' || side === 'outside-bottom') { - return currentPos.top < 0 || currentPos.top + elementDimensions.height > containerDimensions.height - } else { - return currentPos.left < 0 || currentPos.left + elementDimensions.width > containerDimensions.width - } -} - /** * Returns the nearest proper HTMLElement parent of `element` whose * position is not "static", or document.body, whichever is closer @@ -302,9 +202,7 @@ function getDefaultSettings(settings: Partial = {}): PositionS } /** - * Note: This is a pure function with no dependency on DOM APIs (other than DOMRect). Do not - * use this function unless you need a DOM-free, low-level implementaiton. Instead, use - * `getAnchoredPosition`. Position settings not defaulted. + * Note: This is a pure function with no dependency on DOM APIs. * @see getAnchoredPosition * @see getDefaultSettings * @param parentRect BoxPosition for the closest positioned proper parent of the floating element @@ -329,11 +227,11 @@ function pureCalculateAnchoredPosition( if (alternateOrder) { let prevSide = side const containerDimensions = { - // @todo allow custom container dimensions - width: parentRect.width, // Math.max(document.body.scrollWidth, window.innerWidth), - height: parentRect.height //Math.max(document.body.scrollHeight, window.innerHeight) + width: parentRect.width, + height: parentRect.height } + // Try all the alternate sides until one does not overflow while ( positionAttempt < alternateOrder.length && shouldRecalculatePosition(prevSide, pos, containerDimensions, floatingRect) @@ -354,7 +252,9 @@ function pureCalculateAnchoredPosition( if (pos.left < 0) { pos.left = 0 } - + if (pos.left + floatingRect.width > parentRect.width) { + pos.left = parentRect.width - floatingRect.width + } // If we have exhausted all possible positions and none of them worked, we // say that overflowing the bottom of the screen is acceptable since it is // likely to be able to scroll. @@ -363,10 +263,114 @@ function pureCalculateAnchoredPosition( pos.top = parentRect.height - floatingRect.height } } - if (pos.left + floatingRect.width > parentRect.width) { - pos.left = parentRect.width - floatingRect.width - } } - // Adjust for a positioned parent return pos } + +/** + * Given a floating element and an anchor element, return coordinates for the + * top-left of the floating element in order to absolutely position it such + * that it appears near the anchor element. + * + * @param elementDimensions Dimensions of the floating element + * @param anchorPosition Position of the anchor element + * @param side Side of the anchor to position the floating element + * @param align How to align the floating element with the anchor element + * @param anchorOffset Absolute pixel offset for anchor positioning + * @param alignmentOffset Absolute pixel offset for alignment + * @returns {top: number, left: number} coordinates for the floating element + */ +function calculatePosition( + elementDimensions: Size, + anchorPosition: BoxPosition, + side: AnchorSide, + align: AnchoredPositionAlign, + anchorOffset: number, + alignmentOffset: number +) { + const anchorRight = anchorPosition.left + anchorPosition.width + const anchorBottom = anchorPosition.top + anchorPosition.height + let top = -1 + let left = -1 + if (side === 'outside-top') { + top = anchorPosition.top - anchorOffset - elementDimensions.height + } else if (side === 'outside-bottom') { + top = anchorBottom + anchorOffset + } else if (side === 'outside-left') { + left = anchorPosition.left - anchorOffset - elementDimensions.width + } else if (side === 'outside-right') { + left = anchorRight + anchorOffset + } + + if (side === 'outside-top' || side === 'outside-bottom') { + if (align === 'first') { + left = anchorPosition.left + alignmentOffset + } else if (align === 'center') { + left = anchorPosition.left - (elementDimensions.width - anchorPosition.width) / 2 + alignmentOffset + } else if (align === 'last') { + left = anchorRight - elementDimensions.width - alignmentOffset + } + } + + if (side === 'outside-left' || side === 'outside-right') { + if (align === 'first') { + top = anchorPosition.top + alignmentOffset + } else if (align === 'center') { + top = anchorPosition.top - (elementDimensions.height - anchorPosition.height) / 2 + alignmentOffset + } else if (align === 'last') { + top = anchorBottom - elementDimensions.height - alignmentOffset + } + } + + if (side === 'inside-top') { + top = anchorPosition.top + anchorOffset + } else if (side === 'inside-bottom') { + top = anchorBottom - anchorOffset - elementDimensions.height + } else if (side === 'inside-left') { + left = anchorPosition.left + anchorOffset + } else if (side === 'inside-right') { + left = anchorRight - anchorOffset - elementDimensions.width + } else if (side === 'inside-center') { + left = (anchorRight + anchorPosition.left) / 2 - elementDimensions.width / 2 + anchorOffset + } + + if (side === 'inside-top' || side === 'inside-bottom') { + if (align === 'first') { + left = anchorPosition.left + alignmentOffset + } else if (align === 'center') { + left = anchorPosition.left - (elementDimensions.width - anchorPosition.width) / 2 + alignmentOffset + } else if (align === 'last') { + left = anchorRight - elementDimensions.width - alignmentOffset + } + } else if (side === 'inside-left' || side === 'inside-right' || side === 'inside-center') { + if (align === 'first') { + top = anchorPosition.top + alignmentOffset + } else if (align === 'center') { + top = anchorPosition.top - (elementDimensions.height - anchorPosition.height) / 2 + alignmentOffset + } else if (align === 'last') { + top = anchorBottom - elementDimensions.height - alignmentOffset + } + } + + return {top, left} +} + +/** + * Determines if there is an overflow + * @param side + * @param currentPos + * @param containerDimensions + * @param elementDimensions + */ +function shouldRecalculatePosition( + side: AnchorSide, + currentPos: Position, + containerDimensions: Size, + elementDimensions: Size +) { + if (side === 'outside-top' || side === 'outside-bottom') { + return currentPos.top < 0 || currentPos.top + elementDimensions.height > containerDimensions.height + } else { + return currentPos.left < 0 || currentPos.left + elementDimensions.width > containerDimensions.width + } +} From 57e0632074b07fe6a473ec6dee5b5315f0b80250 Mon Sep 17 00:00:00 2001 From: Trevor Gau Date: Wed, 24 Feb 2021 15:32:19 -0500 Subject: [PATCH 05/20] Anchored position docs. --- docs/content/anchoredPosition.mdx | 47 +++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 docs/content/anchoredPosition.mdx diff --git a/docs/content/anchoredPosition.mdx b/docs/content/anchoredPosition.mdx new file mode 100644 index 00000000000..2a7e9a7ad02 --- /dev/null +++ b/docs/content/anchoredPosition.mdx @@ -0,0 +1,47 @@ +--- +title: Anchored Position Behavior +--- + +The `getAnchoredPosition` behavior and `useAnchoredPosition` hook are used to calculate the position of a "floating" element that is anchored to another DOM element. This is useful for implementing overlay UI, such as dialogs, popovers, tooltips, toasts, and dropdown-style menus. + +At a high level, the `getAnchoredPosition` routine will attempt to find the most suitable position for the floating element based on the passed-in settings, its containing element, and the size and position of the anchor element. Specifically, the calculated position should try to ensure that the floating element, when positioned at the calculated coordinates, does not overflow or underflow the container's bounding box. + +Settings for this routine allow the user to customize several aspects of this calculation. See **PositionSettings** below for a detailed description of these settings. + +### Usage + +```ts +const settings = { + side: 'outside-right', + align: 'center', + alignmentOffset: 10, + anchorOffset: -10 +} as Partial +const float = document.getElementById("floatingElement") +const anchor = document.getElementById("anchorElement") +const {top, left} = getAnchoredPosition(float, anchor, settings) +float.style.top = `${top}px` +float.style.left = `${left}px` +``` +### PositionSettings +`PositionSettings` is an object with the following interface +| Name | Type | Default | Description | +| :- | :- | :-: | :- | +| side | AnchorSide | "outside-bottom" | Sets the side of the anchor element that the floating element should be pinned to. This side is given by a string starting with either "inside" or "outside", followed by a hyphen, followed by either "top", "right", "bottom", or "left". Additionally, "inside-center" is an allowed value. + +The first part of this string, "inside" or "outside", determines whether the floating element should be attempted to be placed "inside" the anchor element or "outside" of it. Using "inside" is useful for making it appear that the anchor _contains_ the floating element, and it can be used for implementing a dialog that is centered on the screen. The "outside" value is more common and can be used for tooltips, popovers, menus, etc. + +The second part of this string determines the _edge_ on the anchor element that the floating element will be anchored to. If side is "inside-center", then the floating element will be centered in the X-direction (while align is used to position it in the Y-direction). | +| align | AnchoredPositionAlign | "first" | Determines how the floating element should align with the anchor element. If set to "first", the floating element's first edge (top or left) will align with the anchor element's first edge. If set to "center", the floating element will be centered along the axis of the anchor edge. If set to "last", the floating element's last edge will align with the anchor element's last edge. | +| anchorOffset | number | 4* | The number of pixels between the anchor edge and the floating element. Positive values move the floating element farther from the anchor element (for outside positioning) or further inside the anchor element (for inside positioning). Negative values have the opposite effect. | +| alignmentOffset | number | 4** | An additional offset, in pixels, to move the floating element from the aligning edge. Positive values move the floating element in the direction of center-alignment. Negative values move the floating element away from center-alignment. When align is "center", positive offsets move the floating element right (top or bottom anchor side) or down (left or right anchor side). | +| preventOverflow | boolean | true | If true, when the above settings result in rendering the floating element wholly or partially off-screen, attempt to adjust the settings to prevent this. Only applies to "outside" positioning. + +First, attempt to flip to the opposite edge of the anchor if the floating element is getting clipped in that direction. If flipping results in a similar clipping, try moving to the adjacent sides. + +Once we find a side that does not clip the overlay in its own dimension, check the rest of the sides to see if we need to adjust the alignment offset to fit in other dimensions. + +If we try all four sides and get clipped each time, settle for overflowing and use the "bottom" side, since the ability to scroll is most likely in this direction. | + +\* If `side` is set to `"inside-center"`, this defaults to `0` instead of `4`. +\** If using outside positioning, or if `align` is set to `"center"`, this defaults to `0` instead of `4`. \ No newline at end of file From 4d78ebae9bde3995532155a049198786ea0e7024 Mon Sep 17 00:00:00 2001 From: Trevor Gau Date: Wed, 24 Feb 2021 15:37:32 -0500 Subject: [PATCH 06/20] useAnchoredPosition doc --- docs/content/anchoredPosition.mdx | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/docs/content/anchoredPosition.mdx b/docs/content/anchoredPosition.mdx index 2a7e9a7ad02..72534d51c04 100644 --- a/docs/content/anchoredPosition.mdx +++ b/docs/content/anchoredPosition.mdx @@ -44,4 +44,33 @@ Once we find a side that does not clip the overlay in its own dimension, check t If we try all four sides and get clipped each time, settle for overflowing and use the "bottom" side, since the ability to scroll is most likely in this direction. | \* If `side` is set to `"inside-center"`, this defaults to `0` instead of `4`. -\** If using outside positioning, or if `align` is set to `"center"`, this defaults to `0` instead of `4`. \ No newline at end of file +\** If using outside positioning, or if `align` is set to `"center"`, this defaults to `0` instead of `4`. + +## useAnchoredPosition hook + +The `useAnchoredPosition` hook is used to provide anchored positioning data for React components. The hook returns refs that must be added to the anchor and floating elements, and a `position` object containing `top` and `left`. It is the responsibility of the consumer to apply the top and left styles to the floating element in question. + +### Usage + +```jsx +export const UseAnchoredPosition = () => { + const {floatingElementRef, anchorElementRef, position} = useAnchoredPosition({side: 'outside-bottom', align: 'center'}) + return ( +
+ } + > + Floating element + + }> + Anchor Element + +
+ ) +} +``` \ No newline at end of file From 3a99790aadd1531732e852c752602c9e33dbf7d5 Mon Sep 17 00:00:00 2001 From: Trevor Gau Date: Wed, 24 Feb 2021 15:48:15 -0500 Subject: [PATCH 07/20] Fix anchored position story and line breaks in docs. --- docs/content/anchoredPosition.mdx | 16 +++------------- src/stories/useAnchoredPosition.stories.tsx | 4 ++-- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/docs/content/anchoredPosition.mdx b/docs/content/anchoredPosition.mdx index 72534d51c04..f24c435ba48 100644 --- a/docs/content/anchoredPosition.mdx +++ b/docs/content/anchoredPosition.mdx @@ -27,28 +27,18 @@ float.style.left = `${left}px` `PositionSettings` is an object with the following interface | Name | Type | Default | Description | | :- | :- | :-: | :- | -| side | AnchorSide | "outside-bottom" | Sets the side of the anchor element that the floating element should be pinned to. This side is given by a string starting with either "inside" or "outside", followed by a hyphen, followed by either "top", "right", "bottom", or "left". Additionally, "inside-center" is an allowed value. - -The first part of this string, "inside" or "outside", determines whether the floating element should be attempted to be placed "inside" the anchor element or "outside" of it. Using "inside" is useful for making it appear that the anchor _contains_ the floating element, and it can be used for implementing a dialog that is centered on the screen. The "outside" value is more common and can be used for tooltips, popovers, menus, etc. - -The second part of this string determines the _edge_ on the anchor element that the floating element will be anchored to. If side is "inside-center", then the floating element will be centered in the X-direction (while align is used to position it in the Y-direction). | +| side | AnchorSide | "outside-bottom" | Sets the side of the anchor element that the floating element should be pinned to. This side is given by a string starting with either "inside" or "outside", followed by a hyphen, followed by either "top", "right", "bottom", or "left". Additionally, "inside-center" is an allowed value.

The first part of this string, "inside" or "outside", determines whether the floating element should be attempted to be placed "inside" the anchor element or "outside" of it. Using "inside" is useful for making it appear that the anchor _contains_ the floating element, and it can be used for implementing a dialog that is centered on the screen. The "outside" value is more common and can be used for tooltips, popovers, menus, etc.

The second part of this string determines the _edge_ on the anchor element that the floating element will be anchored to. If side is "inside-center", then the floating element will be centered in the X-direction (while align is used to position it in the Y-direction). | | align | AnchoredPositionAlign | "first" | Determines how the floating element should align with the anchor element. If set to "first", the floating element's first edge (top or left) will align with the anchor element's first edge. If set to "center", the floating element will be centered along the axis of the anchor edge. If set to "last", the floating element's last edge will align with the anchor element's last edge. | | anchorOffset | number | 4* | The number of pixels between the anchor edge and the floating element. Positive values move the floating element farther from the anchor element (for outside positioning) or further inside the anchor element (for inside positioning). Negative values have the opposite effect. | | alignmentOffset | number | 4** | An additional offset, in pixels, to move the floating element from the aligning edge. Positive values move the floating element in the direction of center-alignment. Negative values move the floating element away from center-alignment. When align is "center", positive offsets move the floating element right (top or bottom anchor side) or down (left or right anchor side). | -| preventOverflow | boolean | true | If true, when the above settings result in rendering the floating element wholly or partially off-screen, attempt to adjust the settings to prevent this. Only applies to "outside" positioning. - -First, attempt to flip to the opposite edge of the anchor if the floating element is getting clipped in that direction. If flipping results in a similar clipping, try moving to the adjacent sides. - -Once we find a side that does not clip the overlay in its own dimension, check the rest of the sides to see if we need to adjust the alignment offset to fit in other dimensions. - -If we try all four sides and get clipped each time, settle for overflowing and use the "bottom" side, since the ability to scroll is most likely in this direction. | +| preventOverflow | boolean | true | If true, when the above settings result in rendering the floating element wholly or partially off-screen, attempt to adjust the settings to prevent this. Only applies to "outside" positioning.

First, attempt to flip to the opposite edge of the anchor if the floating element is getting clipped in that direction. If flipping results in a similar clipping, try moving to the adjacent sides.

Once we find a side that does not clip the overlay in its own dimension, check the rest of the sides to see if we need to adjust the alignment offset to fit in other dimensions.

If we try all four sides and get clipped each time, settle for overflowing and use the "bottom" side, since the ability to scroll is most likely in this direction. | \* If `side` is set to `"inside-center"`, this defaults to `0` instead of `4`. \** If using outside positioning, or if `align` is set to `"center"`, this defaults to `0` instead of `4`. ## useAnchoredPosition hook -The `useAnchoredPosition` hook is used to provide anchored positioning data for React components. The hook returns refs that must be added to the anchor and floating elements, and a `position` object containing `top` and `left`. It is the responsibility of the consumer to apply the top and left styles to the floating element in question. +The `useAnchoredPosition` hook is used to provide anchored positioning data for React components. The hook returns refs that must be added to the anchor and floating elements, and a `position` object containing `top` and `left`. This position is tracked as state, so the component will re-render whenever it changes. It is the responsibility of the consumer to apply the top and left styles to the floating element in question. ### Usage diff --git a/src/stories/useAnchoredPosition.stories.tsx b/src/stories/useAnchoredPosition.stories.tsx index 975e54663a6..6fbf848bfbc 100644 --- a/src/stories/useAnchoredPosition.stories.tsx +++ b/src/stories/useAnchoredPosition.stories.tsx @@ -26,7 +26,7 @@ const BorderedPosition = styled(Position)` export const UseAnchoredPosition = () => { const {floatingElementRef, anchorElementRef, position} = useAnchoredPosition({side: 'outside-bottom', align: 'center'}) return ( -
+ { }> Anchor Element -
+ ) } From 63b24be590c0da39b7ac502d45a5117a1912f227 Mon Sep 17 00:00:00 2001 From: Trevor Gau Date: Wed, 24 Feb 2021 15:54:13 -0500 Subject: [PATCH 08/20] Anchored position doc update. --- docs/content/anchoredPosition.mdx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/content/anchoredPosition.mdx b/docs/content/anchoredPosition.mdx index f24c435ba48..6758bae3518 100644 --- a/docs/content/anchoredPosition.mdx +++ b/docs/content/anchoredPosition.mdx @@ -8,6 +8,10 @@ At a high level, the `getAnchoredPosition` routine will attempt to find the most Settings for this routine allow the user to customize several aspects of this calculation. See **PositionSettings** below for a detailed description of these settings. +### Floating element container + +The anchored position calculation is based on the floating element's closest _positioned_ ancestor. In other words, we try to check parents of the floating element until we find one that has a position set to anything other than `static` and use that element's bounding box as the container. If we can't find such an element, we will try to use `document.body`. This element should be large—big enough to accommodate the floating element and have plenty of space to be moved around. It may be a good idea to ensure that this container element _also_ contains the anchor element and is scrollable. This will ensure that when scrolled, the anchor and floating element will move together. + ### Usage ```ts @@ -17,8 +21,8 @@ const settings = { alignmentOffset: 10, anchorOffset: -10 } as Partial -const float = document.getElementById("floatingElement") -const anchor = document.getElementById("anchorElement") +const float = document.getElementById('floatingElement') +const anchor = document.getElementById('anchorElement') const {top, left} = getAnchoredPosition(float, anchor, settings) float.style.top = `${top}px` float.style.left = `${left}px` From 1a4d3db0dc5fe6f05898e9df4c91774d9c0585b0 Mon Sep 17 00:00:00 2001 From: Trevor Gau Date: Wed, 24 Feb 2021 15:54:59 -0500 Subject: [PATCH 09/20] Missing line break --- docs/content/anchoredPosition.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/content/anchoredPosition.mdx b/docs/content/anchoredPosition.mdx index 6758bae3518..d1ffb2c5042 100644 --- a/docs/content/anchoredPosition.mdx +++ b/docs/content/anchoredPosition.mdx @@ -38,6 +38,7 @@ float.style.left = `${left}px` | preventOverflow | boolean | true | If true, when the above settings result in rendering the floating element wholly or partially off-screen, attempt to adjust the settings to prevent this. Only applies to "outside" positioning.

First, attempt to flip to the opposite edge of the anchor if the floating element is getting clipped in that direction. If flipping results in a similar clipping, try moving to the adjacent sides.

Once we find a side that does not clip the overlay in its own dimension, check the rest of the sides to see if we need to adjust the alignment offset to fit in other dimensions.

If we try all four sides and get clipped each time, settle for overflowing and use the "bottom" side, since the ability to scroll is most likely in this direction. | \* If `side` is set to `"inside-center"`, this defaults to `0` instead of `4`. + \** If using outside positioning, or if `align` is set to `"center"`, this defaults to `0` instead of `4`. ## useAnchoredPosition hook From 781cdd71d789e0cd92b5219dc9f9d6be47a075f1 Mon Sep 17 00:00:00 2001 From: Trevor Gau Date: Thu, 25 Feb 2021 09:07:42 -0500 Subject: [PATCH 10/20] Add demo link --- docs/content/anchoredPosition.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/content/anchoredPosition.mdx b/docs/content/anchoredPosition.mdx index d1ffb2c5042..bfdf75a73fb 100644 --- a/docs/content/anchoredPosition.mdx +++ b/docs/content/anchoredPosition.mdx @@ -12,6 +12,10 @@ Settings for this routine allow the user to customize several aspects of this ca The anchored position calculation is based on the floating element's closest _positioned_ ancestor. In other words, we try to check parents of the floating element until we find one that has a position set to anything other than `static` and use that element's bounding box as the container. If we can't find such an element, we will try to use `document.body`. This element should be large—big enough to accommodate the floating element and have plenty of space to be moved around. It may be a good idea to ensure that this container element _also_ contains the anchor element and is scrollable. This will ensure that when scrolled, the anchor and floating element will move together. +### Demo + +See [this CodePen](https://codepen.io/team/GitHub/pen/OJbPKNZ) for a demo of `useAnchoredPosition`. + ### Usage ```ts From aa5f7c0a71d77ae6cc5c6a227c21b0f5b90277cf Mon Sep 17 00:00:00 2001 From: Trevor Gau Date: Thu, 25 Feb 2021 14:39:48 -0500 Subject: [PATCH 11/20] Improve the useAnchoredPosition storybook stories. --- src/stories/useAnchoredPosition.stories.tsx | 143 +++++++++++++++++--- 1 file changed, 123 insertions(+), 20 deletions(-) diff --git a/src/stories/useAnchoredPosition.stories.tsx b/src/stories/useAnchoredPosition.stories.tsx index 6fbf848bfbc..28ed91464da 100644 --- a/src/stories/useAnchoredPosition.stories.tsx +++ b/src/stories/useAnchoredPosition.stories.tsx @@ -2,44 +2,147 @@ import React from 'react' import {Meta} from '@storybook/react' -import {BaseStyles, BorderBox, Position} from '..' +import {BaseStyles, Position} from '..' import {useAnchoredPosition} from '../hooks/useAnchoredPosition' import styled from 'styled-components' +import {get} from '../constants' +import {AnchorSide} from '../behaviors/anchoredPosition' export default { title: 'Hooks/useAnchoredPosition', decorators: [ + // Note: For some reason, if you use , + // the component gets unmounted from the root every time a control changes! Story => { - return ( - - - - ) + return {Story()} } - ] + ], + argTypes: { + anchorX: { + control: {type: 'range', min: 0, max: 500} + }, + anchorY: { + control: {type: 'range', min: 0, max: 500} + }, + anchorWidth: { + control: {type: 'range', min: 50, max: 500} + }, + anchorHeight: { + control: {type: 'range', min: 50, max: 500} + }, + floatWidth: { + control: {type: 'range', min: 50, max: 500} + }, + floatHeight: { + control: {type: 'range', min: 50, max: 500} + }, + anchorPosition: { + control: {type: 'inline-radio', options: ['inside', 'outside']} + }, + anchorSide: { + control: {type: 'inline-radio', options: ['top', 'bottom', 'left', 'right', 'center']}, + description: 'note' + }, + anchorAlignment: { + control: {type: 'inline-radio', options: ['first', 'center', 'last']} + }, + anchorOffset: { + control: {type: 'range', min: -100, max: 100} + }, + alignmentOffset: { + control: {type: 'range', min: -100, max: 100} + }, + preventOverflow: { + control: {type: 'boolean'} + } + } } as Meta -const BorderedPosition = styled(Position)` - border: 1px solid #ccc; +const Float = styled(Position)` + position: absolute; + border: 1px solid ${get('colors.gray.6')}; + border-radius: ${get('radii.2')}; + transition: all 0.2s; + background-color: ${get('colors.orange.3')}; + display: flex; + flex-direction: column; + text-align: center; + font-size: ${get('fontSizes.3')}; + font-weight: ${get('fontWeights.bold')}; + padding: ${get('space.3')}; +` +const Anchor = styled(Position)` + position: absolute; + border: 1px solid ${get('colors.gray.6')}; + border-radius: ${get('radii.2')}; + background-color: ${get('colors.blue.3')}; + display: flex; + flex-direction: column; + text-align: center; + font-size: ${get('fontSizes.3')}; + font-weight: ${get('fontWeights.bold')}; + padding: ${get('space.3')}; ` -export const UseAnchoredPosition = () => { - const {floatingElementRef, anchorElementRef, position} = useAnchoredPosition({side: 'outside-bottom', align: 'center'}) +export const UseAnchoredPosition = (args: any) => { + const {floatingElementRef, anchorElementRef, position} = useAnchoredPosition( + { + side: `${args.anchorPosition ?? 'outside'}-${args.anchorSide ?? 'bottom'}` as AnchorSide, + align: args.anchorAlignment ?? 'first', + anchorOffset: args.anchorOffset && (parseInt(args.anchorOffset, 10) ?? undefined), + alignmentOffset: args.alignmentOffset && (parseInt(args.alignmentOffset, 10) ?? undefined), + preventOverflow: args.preventOverflow ?? undefined + }, + [args] + ) return ( - - + } + > + Anchor Element + + } > Floating element - - }> - Anchor Element - + + + ) +} +export const CenteredOnScreen = (args: any) => { + const {floatingElementRef, anchorElementRef, position} = useAnchoredPosition({ + side: 'inside-center', + align: 'center' + }) + // The outer Position element simply fills all available space + return ( + } + position="absolute" + top={0} + bottom={0} + left={0} + right={0} + > + } + top={position?.top ?? 0} + left={position?.left ?? 0} + > +

Screen-Centered Floating Element

+

+ (Controls are ignored for this story) +

+
) } From 0379019c46509841d9ebeafc529eedcff582aff8 Mon Sep 17 00:00:00 2001 From: Trevor Gau Date: Thu, 25 Feb 2021 14:40:52 -0500 Subject: [PATCH 12/20] Fix warning about PortalProps not being exported. --- src/Portal/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Portal/index.ts b/src/Portal/index.ts index b644e02340a..ca0def91724 100644 --- a/src/Portal/index.ts +++ b/src/Portal/index.ts @@ -1,4 +1,5 @@ import {Portal, PortalProps, registerPortalRoot} from "./Portal" export default Portal -export {registerPortalRoot, PortalProps} \ No newline at end of file +export {registerPortalRoot} +export type {PortalProps} \ No newline at end of file From 1d5070018a16ca507c1661f2bc18e5df44b7db8b Mon Sep 17 00:00:00 2001 From: Trevor Gau Date: Fri, 26 Feb 2021 10:44:35 -0500 Subject: [PATCH 13/20] Add a Portal+useAnchoredPosition story. --- src/stories/Portal.stories.tsx | 2 +- src/stories/useAnchoredPosition.stories.tsx | 103 +++++++++++++++++++- 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/src/stories/Portal.stories.tsx b/src/stories/Portal.stories.tsx index a84646adf58..8bf513df796 100644 --- a/src/stories/Portal.stories.tsx +++ b/src/stories/Portal.stories.tsx @@ -58,7 +58,7 @@ export const CustomPortalRootByRegistration: React.FC> = ( registerPortalRoot(outerContainerRef.current) setMounted(true) } - }, [outerContainerRef]) + }, []) return ( <> Root position diff --git a/src/stories/useAnchoredPosition.stories.tsx b/src/stories/useAnchoredPosition.stories.tsx index 28ed91464da..f88c538c704 100644 --- a/src/stories/useAnchoredPosition.stories.tsx +++ b/src/stories/useAnchoredPosition.stories.tsx @@ -2,11 +2,12 @@ import React from 'react' import {Meta} from '@storybook/react' -import {BaseStyles, Position} from '..' +import {BaseStyles, Box, ButtonPrimary, Position} from '..' import {useAnchoredPosition} from '../hooks/useAnchoredPosition' import styled from 'styled-components' import {get} from '../constants' import {AnchorSide} from '../behaviors/anchoredPosition' +import Portal, {registerPortalRoot} from '../Portal' export default { title: 'Hooks/useAnchoredPosition', @@ -62,7 +63,6 @@ const Float = styled(Position)` position: absolute; border: 1px solid ${get('colors.gray.6')}; border-radius: ${get('radii.2')}; - transition: all 0.2s; background-color: ${get('colors.orange.3')}; display: flex; flex-direction: column; @@ -140,9 +140,106 @@ export const CenteredOnScreen = (args: any) => { >

Screen-Centered Floating Element

- (Controls are ignored for this story) + + (Controls are ignored for this story) +

) } + +const Nav = styled('nav')` + width: 300px; + padding: ${get('space.3')}; + position: relative; + overflow: hidden; + border-right: 1px solid ${get('colors.border.gray')}; +` +const Main = styled('main')` + display: flex; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +` + +/* + +There are a few "gotchas" to take note of from this example. + +1. The portal's root (
in this example) needs to be large enough + to include ANY space that the overlay might need to take. By default, + elements are not rendered at full height! Notice how
uses + top, left, right, and bottom all set to 0 to achieve a full-size box. + +2. The positioning routine needs to know the size of the overlay before + calculating its position! Therefore, we use visibility: hidden to + prevent showing a single frame of the overlay being positioned at + (0, 0). + + +*/ + +export const WithPortal = (args: any) => { + const [showMenu, setShowMenu] = React.useState(false) + const mainRef = React.useRef(null) + + // Calculate the position of the menu + const {floatingElementRef, anchorElementRef, position} = useAnchoredPosition( + { + side: 'outside-bottom', + align: 'first' + }, + [showMenu] + ) + + // Register
as the Portal root + React.useEffect(() => { + if (mainRef.current) { + registerPortalRoot(mainRef.current) + } + }, [mainRef]) + + // Toggles rendering the menu when the button is clicked + const toggleMenu = React.useCallback(() => { + setShowMenu(!showMenu) + }, [showMenu]) + + return ( +
+ + +

The body!

+

Note: The controls below have no effect in this story.

+
+
+ ) +} From c8648bce4698de2b49b09ac10a13c2df6f9845ff Mon Sep 17 00:00:00 2001 From: Trevor Gau Date: Fri, 26 Feb 2021 11:11:48 -0500 Subject: [PATCH 14/20] Fix doc site rendering. --- docs/content/anchoredPosition.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/content/anchoredPosition.mdx b/docs/content/anchoredPosition.mdx index bfdf75a73fb..5cedd687404 100644 --- a/docs/content/anchoredPosition.mdx +++ b/docs/content/anchoredPosition.mdx @@ -33,6 +33,7 @@ float.style.left = `${left}px` ``` ### PositionSettings `PositionSettings` is an object with the following interface + | Name | Type | Default | Description | | :- | :- | :-: | :- | | side | AnchorSide | "outside-bottom" | Sets the side of the anchor element that the floating element should be pinned to. This side is given by a string starting with either "inside" or "outside", followed by a hyphen, followed by either "top", "right", "bottom", or "left". Additionally, "inside-center" is an allowed value.

The first part of this string, "inside" or "outside", determines whether the floating element should be attempted to be placed "inside" the anchor element or "outside" of it. Using "inside" is useful for making it appear that the anchor _contains_ the floating element, and it can be used for implementing a dialog that is centered on the screen. The "outside" value is more common and can be used for tooltips, popovers, menus, etc.

The second part of this string determines the _edge_ on the anchor element that the floating element will be anchored to. If side is "inside-center", then the floating element will be centered in the X-direction (while align is used to position it in the Y-direction). | From 3a5ced8325acb876bbd8dc1464e075a0c62e4bef Mon Sep 17 00:00:00 2001 From: Trevor Gau Date: Fri, 26 Feb 2021 16:22:28 -0500 Subject: [PATCH 15/20] Respond to live session PR feedback. --- docs/content/anchoredPosition.mdx | 22 ++++++++++++++++++++- src/behaviors/anchoredPosition.ts | 18 ++++++++--------- src/hooks/useAnchoredPosition.ts | 2 +- src/stories/useAnchoredPosition.stories.tsx | 12 +++++------ 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/docs/content/anchoredPosition.mdx b/docs/content/anchoredPosition.mdx index 5cedd687404..1f6091c6c76 100644 --- a/docs/content/anchoredPosition.mdx +++ b/docs/content/anchoredPosition.mdx @@ -46,10 +46,22 @@ float.style.left = `${left}px` \** If using outside positioning, or if `align` is set to `"center"`, this defaults to `0` instead of `4`. +### A note on performance + +Every time `getAnchoredPosition` is called, it causes a [reflow](https://developers.google.com/speed/docs/insights/browser-reflow) because it needs to query the rendering engine for the positions of 3 elements: the anchor element, the floating element, and the closest ancestor of the floating element that is _positioned_. Therefore, this function should not be called until it is needed (e.g. an overlay-style menu is invoked and displayed). + ## useAnchoredPosition hook The `useAnchoredPosition` hook is used to provide anchored positioning data for React components. The hook returns refs that must be added to the anchor and floating elements, and a `position` object containing `top` and `left`. This position is tracked as state, so the component will re-render whenever it changes. It is the responsibility of the consumer to apply the top and left styles to the floating element in question. +### Using your own refs + +The `useAnchoredPosition` hook will return two refs for the anchor element and the floating element, which must be added to their respective JSX. If you would like to use your own refs, you can pass them into the hook as part of the settings object (see the interface below). + +### Recalculating position + +Like other hooks such as `useCallback` and `useEffect`, this hook takes a dependencies array. If defined, the position will only be recalulated when one of the dependencies in this array changes. Otherwise, the position will be calculated when the component is first mounted, but never again. + ### Usage ```jsx @@ -73,4 +85,12 @@ export const UseAnchoredPosition = () => { ) } -``` \ No newline at end of file +``` + +### UseAnchoredPositionSettings +`UseAnchoredPositionSettings` is an object with an interface that extends `PositionSettings` (see above). Additionally, it adds the following properties: + +| Name | Type | Default | Description | +| :- | :- | :-: | :- | +| floatingElementRef | React.RefObject | undefined | If provided, this will be the ref used to access the element that will be used for the floating element. Its size measurements are needed by the underlying `useAnchoredPosition` routine. Otherwise, this hook will create the ref for you and return it. In both cases, the ref must be provided to the floating element's JSX. | +| anchorElementRef | React.RefObject | undefined | If provided, this will be the ref used to access the element that will be used for the anchor element. Its position and size measurements are needed by the underlying `useAnchoredPosition` routine. Otherwise, this hook will create the ref for you and return it. In both cases, the ref must be provided to the anchor element's JSX. | \ No newline at end of file diff --git a/src/behaviors/anchoredPosition.ts b/src/behaviors/anchoredPosition.ts index 80538ff9a38..7214d7dc9a2 100644 --- a/src/behaviors/anchoredPosition.ts +++ b/src/behaviors/anchoredPosition.ts @@ -70,9 +70,9 @@ export interface PositionSettings { alignmentOffset: number /** - * If true, when the above settings result in rendering the floating element - * wholly or partially off-screen, attempt to adjust the settings to prevent - * this. Only applies to "outside" positioning. + * If false, when the above settings result in rendering the floating element + * wholly or partially outside of the bounds of the containing element, attempt + * to adjust the settings to prevent this. Only applies to "outside" positioning. * * First, attempt to flip to the opposite edge of the anchor if the floating * element is getting clipped in that direction. If flipping results in a @@ -86,11 +86,11 @@ export interface PositionSettings { * and use the "bottom" side, since the ability to scroll is most likely in * this direction. */ - preventOverflow: boolean + allowOutOfBounds: boolean } // For each outside anchor position, list the order of alternate positions to try in -// the event that the original position overflows. See comment on `preventOverflow` +// the event that the original position overflows. See comment on `allowOutOfBounds` // for a more detailed description. const alternateOrders: Partial> = { 'outside-top': ['outside-bottom', 'outside-right', 'outside-left', 'outside-bottom'], @@ -178,7 +178,7 @@ const positionDefaults: PositionSettings = { // and align is not center alignmentOffset: 4, - preventOverflow: true + allowOutOfBounds: false } /** @@ -197,7 +197,7 @@ function getDefaultSettings(settings: Partial = {}): PositionS alignmentOffset: settings.alignmentOffset ?? (align !== 'center' && side.startsWith('inside') ? positionDefaults.alignmentOffset : 0), - preventOverflow: settings.preventOverflow ?? positionDefaults.preventOverflow + allowOutOfBounds: settings.allowOutOfBounds ?? positionDefaults.allowOutOfBounds } } @@ -214,14 +214,14 @@ function pureCalculateAnchoredPosition( parentRect: BoxPosition, floatingRect: Size, anchorRect: BoxPosition, - {side, align, preventOverflow, anchorOffset, alignmentOffset}: PositionSettings + {side, align, allowOutOfBounds, anchorOffset, alignmentOffset}: PositionSettings ): {top: number; left: number} { let pos = calculatePosition(floatingRect, anchorRect, side, align, anchorOffset, alignmentOffset) pos.top -= parentRect.top pos.left -= parentRect.left // Handle screen overflow - if (preventOverflow) { + if (!allowOutOfBounds) { const alternateOrder = alternateOrders[side] let positionAttempt = 0 if (alternateOrder) { diff --git a/src/hooks/useAnchoredPosition.ts b/src/hooks/useAnchoredPosition.ts index ee1d13a8a53..1267cb82c9f 100644 --- a/src/hooks/useAnchoredPosition.ts +++ b/src/hooks/useAnchoredPosition.ts @@ -32,7 +32,7 @@ export function useAnchoredPosition( setPosition(getAnchoredPosition(floatingElementRef.current, anchorElementRef.current, settings)) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, dependencies) + }, dependencies ?? []) return { floatingElementRef, anchorElementRef, diff --git a/src/stories/useAnchoredPosition.stories.tsx b/src/stories/useAnchoredPosition.stories.tsx index f88c538c704..80f9892dd27 100644 --- a/src/stories/useAnchoredPosition.stories.tsx +++ b/src/stories/useAnchoredPosition.stories.tsx @@ -53,7 +53,7 @@ export default { alignmentOffset: { control: {type: 'range', min: -100, max: 100} }, - preventOverflow: { + allowOutOfBounds: { control: {type: 'boolean'} } } @@ -91,7 +91,7 @@ export const UseAnchoredPosition = (args: any) => { align: args.anchorAlignment ?? 'first', anchorOffset: args.anchorOffset && (parseInt(args.anchorOffset, 10) ?? undefined), alignmentOffset: args.alignmentOffset && (parseInt(args.alignmentOffset, 10) ?? undefined), - preventOverflow: args.preventOverflow ?? undefined + allowOutOfBounds: args.allowOutOfBounds ?? undefined }, [args] ) @@ -118,7 +118,7 @@ export const UseAnchoredPosition = (args: any) => { ) } -export const CenteredOnScreen = (args: any) => { +export const CenteredOnScreen = () => { const {floatingElementRef, anchorElementRef, position} = useAnchoredPosition({ side: 'inside-center', align: 'center' @@ -167,7 +167,8 @@ const Main = styled('main')` /* -There are a few "gotchas" to take note of from this example. +There are a few "gotchas" to take note of from this example. See the +documentation for more info. 1. The portal's root (
in this example) needs to be large enough to include ANY space that the overlay might need to take. By default, @@ -179,10 +180,9 @@ There are a few "gotchas" to take note of from this example. prevent showing a single frame of the overlay being positioned at (0, 0). - */ -export const WithPortal = (args: any) => { +export const WithPortal = () => { const [showMenu, setShowMenu] = React.useState(false) const mainRef = React.useRef(null) From c89a33f26225d9b0c6c28941f24ce86cd4effdfa Mon Sep 17 00:00:00 2001 From: Trevor Gau Date: Fri, 26 Feb 2021 16:24:43 -0500 Subject: [PATCH 16/20] Disable a linter warning. --- src/stories/useAnchoredPosition.stories.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/stories/useAnchoredPosition.stories.tsx b/src/stories/useAnchoredPosition.stories.tsx index 80f9892dd27..e6e13afa677 100644 --- a/src/stories/useAnchoredPosition.stories.tsx +++ b/src/stories/useAnchoredPosition.stories.tsx @@ -84,6 +84,7 @@ const Anchor = styled(Position)` padding: ${get('space.3')}; ` +// eslint-disable-next-line @typescript-eslint/no-explicit-any export const UseAnchoredPosition = (args: any) => { const {floatingElementRef, anchorElementRef, position} = useAnchoredPosition( { From b943bd124a59a34d5252944bfe20977f09a35f03 Mon Sep 17 00:00:00 2001 From: Trevor Gau Date: Tue, 2 Mar 2021 12:58:55 -0500 Subject: [PATCH 17/20] Add backticks in doc Co-authored-by: emplums --- docs/content/anchoredPosition.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/content/anchoredPosition.mdx b/docs/content/anchoredPosition.mdx index 1f6091c6c76..5fdb035c380 100644 --- a/docs/content/anchoredPosition.mdx +++ b/docs/content/anchoredPosition.mdx @@ -36,7 +36,7 @@ float.style.left = `${left}px` | Name | Type | Default | Description | | :- | :- | :-: | :- | -| side | AnchorSide | "outside-bottom" | Sets the side of the anchor element that the floating element should be pinned to. This side is given by a string starting with either "inside" or "outside", followed by a hyphen, followed by either "top", "right", "bottom", or "left". Additionally, "inside-center" is an allowed value.

The first part of this string, "inside" or "outside", determines whether the floating element should be attempted to be placed "inside" the anchor element or "outside" of it. Using "inside" is useful for making it appear that the anchor _contains_ the floating element, and it can be used for implementing a dialog that is centered on the screen. The "outside" value is more common and can be used for tooltips, popovers, menus, etc.

The second part of this string determines the _edge_ on the anchor element that the floating element will be anchored to. If side is "inside-center", then the floating element will be centered in the X-direction (while align is used to position it in the Y-direction). | +| side | AnchorSide | "outside-bottom" | Sets the side of the anchor element that the floating element should be pinned to. This side is given by a string starting with either "inside" or "outside", followed by a hyphen, followed by either "top", "right", "bottom", or "left". Additionally, "inside-center" is an allowed value.

The first part of this string, "inside" or "outside", determines whether the floating element should be attempted to be placed "inside" the anchor element or "outside" of it. Using "inside" is useful for making it appear that the anchor _contains_ the floating element, and it can be used for implementing a dialog that is centered on the screen. The "outside" value is more common and can be used for tooltips, popovers, menus, etc.

The second part of this string determines the _edge_ on the anchor element that the floating element will be anchored to. If side is "inside-center", then the floating element will be centered in the X-direction (while `align` is used to position it in the Y-direction). | | align | AnchoredPositionAlign | "first" | Determines how the floating element should align with the anchor element. If set to "first", the floating element's first edge (top or left) will align with the anchor element's first edge. If set to "center", the floating element will be centered along the axis of the anchor edge. If set to "last", the floating element's last edge will align with the anchor element's last edge. | | anchorOffset | number | 4* | The number of pixels between the anchor edge and the floating element. Positive values move the floating element farther from the anchor element (for outside positioning) or further inside the anchor element (for inside positioning). Negative values have the opposite effect. | | alignmentOffset | number | 4** | An additional offset, in pixels, to move the floating element from the aligning edge. Positive values move the floating element in the direction of center-alignment. Negative values move the floating element away from center-alignment. When align is "center", positive offsets move the floating element right (top or bottom anchor side) or down (left or right anchor side). | @@ -93,4 +93,4 @@ export const UseAnchoredPosition = () => { | Name | Type | Default | Description | | :- | :- | :-: | :- | | floatingElementRef | React.RefObject | undefined | If provided, this will be the ref used to access the element that will be used for the floating element. Its size measurements are needed by the underlying `useAnchoredPosition` routine. Otherwise, this hook will create the ref for you and return it. In both cases, the ref must be provided to the floating element's JSX. | -| anchorElementRef | React.RefObject | undefined | If provided, this will be the ref used to access the element that will be used for the anchor element. Its position and size measurements are needed by the underlying `useAnchoredPosition` routine. Otherwise, this hook will create the ref for you and return it. In both cases, the ref must be provided to the anchor element's JSX. | \ No newline at end of file +| anchorElementRef | React.RefObject | undefined | If provided, this will be the ref used to access the element that will be used for the anchor element. Its position and size measurements are needed by the underlying `useAnchoredPosition` routine. Otherwise, this hook will create the ref for you and return it. In both cases, the ref must be provided to the anchor element's JSX. | From e8205e1b2127ee5750b5c57fa7f457c8ba130b85 Mon Sep 17 00:00:00 2001 From: Trevor Gau Date: Tue, 2 Mar 2021 15:07:08 -0500 Subject: [PATCH 18/20] Respond to PR feedback - mostly doc improvements --- docs/content/anchoredPosition.mdx | 75 +++++++++++++++++++++++++------ src/behaviors/anchoredPosition.ts | 28 ++++++------ 2 files changed, 76 insertions(+), 27 deletions(-) diff --git a/docs/content/anchoredPosition.mdx b/docs/content/anchoredPosition.mdx index 5fdb035c380..42eb0c78ecb 100644 --- a/docs/content/anchoredPosition.mdx +++ b/docs/content/anchoredPosition.mdx @@ -4,13 +4,32 @@ title: Anchored Position Behavior The `getAnchoredPosition` behavior and `useAnchoredPosition` hook are used to calculate the position of a "floating" element that is anchored to another DOM element. This is useful for implementing overlay UI, such as dialogs, popovers, tooltips, toasts, and dropdown-style menus. -At a high level, the `getAnchoredPosition` routine will attempt to find the most suitable position for the floating element based on the passed-in settings, its containing element, and the size and position of the anchor element. Specifically, the calculated position should try to ensure that the floating element, when positioned at the calculated coordinates, does not overflow or underflow the container's bounding box. +At a high level, the `getAnchoredPosition` algorithm will attempt to find the most suitable position for the floating element based on the passed-in settings, its containing element, and the size and position of the anchor element. Specifically, the calculated position should try to ensure that the floating element, when positioned at the calculated coordinates, does not overflow or underflow the container's bounding box. -Settings for this routine allow the user to customize several aspects of this calculation. See **PositionSettings** below for a detailed description of these settings. +Settings for this behavior allow the user to customize several aspects of this calculation. See **PositionSettings** below for a detailed description of these settings. -### Floating element container +### Positioning algorithm -The anchored position calculation is based on the floating element's closest _positioned_ ancestor. In other words, we try to check parents of the floating element until we find one that has a position set to anything other than `static` and use that element's bounding box as the container. If we can't find such an element, we will try to use `document.body`. This element should be large—big enough to accommodate the floating element and have plenty of space to be moved around. It may be a good idea to ensure that this container element _also_ contains the anchor element and is scrollable. This will ensure that when scrolled, the anchor and floating element will move together. +When calculating the position of the floating element, the algorithm relies on different measurements from three separate elements: + +1. The floating element's width and height +2. The anchor element's x/y position and its width and height +3. The floating element container's x/y position, width and heigh, and border sizes + +The public API only asks for the first two elements; the floating element's container is discovered via DOM traversal. + +#### Finding the floating element's container + +The returned anchored position calculation is relative to the floating element's closest [_positioned_](https://developer.mozilla.org/en-US/docs/Web/CSS/position#types_of_positioning) ancestor. To find this ancestor, we try to check parents of the floating element until we find one that has a position set to anything other than `static` and use that element's bounding box as the container. If we can't find such an element, we will try to use `document.body`. This element should be large—big enough to accommodate the floating element and have plenty of space to be moved around. It may be a good idea to ensure that this container element _also_ contains the anchor element and is scrollable. This will ensure that when scrolled, the anchor and floating element will move together. + +#### Positioning and overflow + +With the positions and sizes of the above DOM elements, the algorithm calculates the (x, y) coordinate for the floating element. Then, it checks to see if, based on the floating element's size, if it would overflow the bounds of the container. If it would, it does one of two things: + +A) If the overflow happens in the same direction as the anchor side (e.g. side is 'outside-bottom' and the overflowing portion of the floating element is the bottom), try to find a different side, recalculate the position, and check for overflow again. If we check all four sides and don't find one that fits, revert to the bottom side, in hopes that a scrollbar might appear. +B) Otherwise, adjust the alignment offest so that the floating element can stay inside the container's bounds. + +For a more in-depth explanation of the positioning settings, see `PositionSettings` below. ### Demo @@ -31,24 +50,54 @@ const {top, left} = getAnchoredPosition(float, anchor, settings) float.style.top = `${top}px` float.style.left = `${left}px` ``` -### PositionSettings + +### API + +The `getAnchoredPosition` function takes the following arguments. + +| Name | Type | Default | Description | +| :- | :- | :-: | :- | +| floatingElement | `Element` | | This is an Element that is currently rendered on the page. `getAnchoredPosition` needs to be able to measure this element's `width` and `height`. | +| anchorElement | `Element` | | This is an Element that the floating element will be "anchored" to. In other words, the calculated position of the floating element will be based on this element's position and size. | +| settings | `PositionSettings` | `{}` | Settings to customize the positioning algorithm. See below for a description of each setting. | + +#### PositionSettings `PositionSettings` is an object with the following interface | Name | Type | Default | Description | | :- | :- | :-: | :- | -| side | AnchorSide | "outside-bottom" | Sets the side of the anchor element that the floating element should be pinned to. This side is given by a string starting with either "inside" or "outside", followed by a hyphen, followed by either "top", "right", "bottom", or "left". Additionally, "inside-center" is an allowed value.

The first part of this string, "inside" or "outside", determines whether the floating element should be attempted to be placed "inside" the anchor element or "outside" of it. Using "inside" is useful for making it appear that the anchor _contains_ the floating element, and it can be used for implementing a dialog that is centered on the screen. The "outside" value is more common and can be used for tooltips, popovers, menus, etc.

The second part of this string determines the _edge_ on the anchor element that the floating element will be anchored to. If side is "inside-center", then the floating element will be centered in the X-direction (while `align` is used to position it in the Y-direction). | -| align | AnchoredPositionAlign | "first" | Determines how the floating element should align with the anchor element. If set to "first", the floating element's first edge (top or left) will align with the anchor element's first edge. If set to "center", the floating element will be centered along the axis of the anchor edge. If set to "last", the floating element's last edge will align with the anchor element's last edge. | -| anchorOffset | number | 4* | The number of pixels between the anchor edge and the floating element. Positive values move the floating element farther from the anchor element (for outside positioning) or further inside the anchor element (for inside positioning). Negative values have the opposite effect. | -| alignmentOffset | number | 4** | An additional offset, in pixels, to move the floating element from the aligning edge. Positive values move the floating element in the direction of center-alignment. Negative values move the floating element away from center-alignment. When align is "center", positive offsets move the floating element right (top or bottom anchor side) or down (left or right anchor side). | -| preventOverflow | boolean | true | If true, when the above settings result in rendering the floating element wholly or partially off-screen, attempt to adjust the settings to prevent this. Only applies to "outside" positioning.

First, attempt to flip to the opposite edge of the anchor if the floating element is getting clipped in that direction. If flipping results in a similar clipping, try moving to the adjacent sides.

Once we find a side that does not clip the overlay in its own dimension, check the rest of the sides to see if we need to adjust the alignment offset to fit in other dimensions.

If we try all four sides and get clipped each time, settle for overflowing and use the "bottom" side, since the ability to scroll is most likely in this direction. | +| side | `AnchorSide` | `"outside-bottom"` | Sets the side of the anchor element that the floating element should be pinned to. This side is given by a string starting with either "inside" or "outside", followed by a hyphen, followed by either "top", "right", "bottom", or "left". Additionally, "inside-center" is an allowed value.

The first part of this string, "inside" or "outside", determines whether the floating element should be attempted to be placed "inside" the anchor element or "outside" of it. Using "inside" is useful for making it appear that the anchor _contains_ the floating element, and it can be used for implementing a dialog that is centered on the screen. The "outside" value is more common and can be used for tooltips, popovers, menus, etc.

The second part of this string determines the _edge_ on the anchor element that the floating element will be anchored to. If side is "inside-center", then the floating element will be centered in the X-direction (while `align` is used to position it in the Y-direction). | +| align | `AnchorAlignment` | `"start"` | Determines how the floating element should align with the anchor element. If set to "start", the floating element's first edge (top or left) will align with the anchor element's first edge. If set to "center", the floating element will be centered along the axis of the anchor edge. If set to "end", the floating element's last edge will align with the anchor element's last edge. | +| anchorOffset | `number` | `4`* | The number of pixels between the anchor edge and the floating element. Positive values move the floating element farther from the anchor element (for outside positioning) or further inside the anchor element (for inside positioning). Negative values have the opposite effect. | +| alignmentOffset | `number` | `4`** | An additional offset, in pixels, to move the floating element from the aligning edge. Positive values move the floating element in the direction of center-alignment. Negative values move the floating element away from center-alignment. When align is "center", positive offsets move the floating element right (top or bottom anchor side) or down (left or right anchor side). | +| allowOutOfBounds | `boolean` | `false` | If false, when the above settings result in rendering the floating element wholly or partially off-screen, attempt to adjust the settings to prevent this. Only applies to "outside" positioning.

First, attempt to flip to the opposite edge of the anchor if the floating element is getting clipped in that direction. If flipping results in a similar clipping, try moving to the adjacent sides.

Once we find a side that does not clip the overlay in its own dimension, check the rest of the sides to see if we need to adjust the alignment offset to fit in other dimensions.

If we try all four sides and get clipped each time, settle for overflowing and use the "bottom" side, since the ability to scroll is most likely in this direction. | \* If `side` is set to `"inside-center"`, this defaults to `0` instead of `4`. \** If using outside positioning, or if `align` is set to `"center"`, this defaults to `0` instead of `4`. +#### AnchorSide + +`AnchorSide` can be any of the following strings: + +`'inside-top'`, `'inside-bottom'`, `'inside-left'`, `'inside-right'`, `'inside-center'`, `'outside-top'`, `'outside-bottom'`, `'outside-left'`, `'outside-right'` + +#### AnchorAlignment + +`AnchorAlignment` can be any of the following strings: + +`'start'`, `'center'`, `'end'` + +### Best practices + +As discussed above, the positioning algorithm needs to first measure the size of three different elements. Therefore, all three of these elements (anchor element, floating element, and the floating element's closest positioned container) must be rendered at the time `getAnchoredPosition` is called. Use these tips to get the best results: + +1. To avoid a frame where the floating element is rendered at the `(0, 0)` position, give it a style of `visibility: hidden` until its position is returned at set. This allows the element to be measured without showing up on the page. +2. When checking for overflow, the positioning algorithm checks that the floating element at its calculated coordinates completely fits within its closest [_positioned_](https://developer.mozilla.org/en-US/docs/Web/CSS/position#types_of_positioning) ancestor. Therefore, such a container should completely fill _its_ parent container without relying on automatic sizing or overflow. + ### A note on performance -Every time `getAnchoredPosition` is called, it causes a [reflow](https://developers.google.com/speed/docs/insights/browser-reflow) because it needs to query the rendering engine for the positions of 3 elements: the anchor element, the floating element, and the closest ancestor of the floating element that is _positioned_. Therefore, this function should not be called until it is needed (e.g. an overlay-style menu is invoked and displayed). +Every time `getAnchoredPosition` is called, it causes a [reflow](https://developers.google.com/speed/docs/insights/browser-reflow) because it needs to query the rendering engine for the positions of 3 elements: the anchor element, the floating element, and the closest ancestor of the floating element that is [_positioned_](https://developer.mozilla.org/en-US/docs/Web/CSS/position#types_of_positioning). Therefore, this function should not be called until it is needed (e.g. an overlay-style menu is invoked and displayed). ## useAnchoredPosition hook @@ -92,5 +141,5 @@ export const UseAnchoredPosition = () => { | Name | Type | Default | Description | | :- | :- | :-: | :- | -| floatingElementRef | React.RefObject | undefined | If provided, this will be the ref used to access the element that will be used for the floating element. Its size measurements are needed by the underlying `useAnchoredPosition` routine. Otherwise, this hook will create the ref for you and return it. In both cases, the ref must be provided to the floating element's JSX. | -| anchorElementRef | React.RefObject | undefined | If provided, this will be the ref used to access the element that will be used for the anchor element. Its position and size measurements are needed by the underlying `useAnchoredPosition` routine. Otherwise, this hook will create the ref for you and return it. In both cases, the ref must be provided to the anchor element's JSX. | +| floatingElementRef | React.RefObject | undefined | If provided, this will be the ref used to access the element that will be used for the floating element. Its size measurements are needed by the underlying `useAnchoredPosition` behavior. Otherwise, this hook will create the ref for you and return it. In both cases, the ref must be provided to the floating element's JSX. | +| anchorElementRef | React.RefObject | undefined | If provided, this will be the ref used to access the element that will be used for the anchor element. Its position and size measurements are needed by the underlying `useAnchoredPosition` behavior. Otherwise, this hook will create the ref for you and return it. In both cases, the ref must be provided to the anchor element's JSX. | diff --git a/src/behaviors/anchoredPosition.ts b/src/behaviors/anchoredPosition.ts index 7214d7dc9a2..b4ae8e3dd62 100644 --- a/src/behaviors/anchoredPosition.ts +++ b/src/behaviors/anchoredPosition.ts @@ -1,4 +1,4 @@ -export type AnchoredPositionAlign = 'first' | 'center' | 'last' +export type AnchorAlignment = 'start' | 'center' | 'end' // When prettier supports template literal types... // export type AnchorSide = `${'inside' | 'outside'}-${'top' | 'bottom' | 'right' | 'left'}` | 'inside-center' @@ -41,12 +41,12 @@ export interface PositionSettings { /** * Determines how the floating element should align with the anchor element. If - * set to "first", the floating element's first edge (top or left) will align + * set to "start", the floating element's first edge (top or left) will align * with the anchor element's first edge. If set to "center", the floating - * element will be centered along the axis of the anchor edge. If set to "last", + * element will be centered along the axis of the anchor edge. If set to "end", * the floating element's last edge will align with the anchor element's last edge. */ - align: AnchoredPositionAlign + align: AnchorAlignment /** * The number of pixels between the anchor edge and the floating element. @@ -169,7 +169,7 @@ function getPositionedParent(element: Element) { // Default settings to position a floating element const positionDefaults: PositionSettings = { side: 'outside-bottom', - align: 'first', + align: 'start', // note: the following default is not applied if side === "inside-center" anchorOffset: 4, @@ -284,7 +284,7 @@ function calculatePosition( elementDimensions: Size, anchorPosition: BoxPosition, side: AnchorSide, - align: AnchoredPositionAlign, + align: AnchorAlignment, anchorOffset: number, alignmentOffset: number ) { @@ -303,21 +303,21 @@ function calculatePosition( } if (side === 'outside-top' || side === 'outside-bottom') { - if (align === 'first') { + if (align === 'start') { left = anchorPosition.left + alignmentOffset } else if (align === 'center') { left = anchorPosition.left - (elementDimensions.width - anchorPosition.width) / 2 + alignmentOffset - } else if (align === 'last') { + } else if (align === 'end') { left = anchorRight - elementDimensions.width - alignmentOffset } } if (side === 'outside-left' || side === 'outside-right') { - if (align === 'first') { + if (align === 'start') { top = anchorPosition.top + alignmentOffset } else if (align === 'center') { top = anchorPosition.top - (elementDimensions.height - anchorPosition.height) / 2 + alignmentOffset - } else if (align === 'last') { + } else if (align === 'end') { top = anchorBottom - elementDimensions.height - alignmentOffset } } @@ -335,19 +335,19 @@ function calculatePosition( } if (side === 'inside-top' || side === 'inside-bottom') { - if (align === 'first') { + if (align === 'start') { left = anchorPosition.left + alignmentOffset } else if (align === 'center') { left = anchorPosition.left - (elementDimensions.width - anchorPosition.width) / 2 + alignmentOffset - } else if (align === 'last') { + } else if (align === 'end') { left = anchorRight - elementDimensions.width - alignmentOffset } } else if (side === 'inside-left' || side === 'inside-right' || side === 'inside-center') { - if (align === 'first') { + if (align === 'start') { top = anchorPosition.top + alignmentOffset } else if (align === 'center') { top = anchorPosition.top - (elementDimensions.height - anchorPosition.height) / 2 + alignmentOffset - } else if (align === 'last') { + } else if (align === 'end') { top = anchorBottom - elementDimensions.height - alignmentOffset } } From 4e7e719ac54b926c5f1b18f06d590e56dd46e5f2 Mon Sep 17 00:00:00 2001 From: Trevor Gau Date: Tue, 2 Mar 2021 15:10:26 -0500 Subject: [PATCH 19/20] Fix up a couple places after API change. --- src/__tests__/behaviors/anchoredPosition.ts | 4 ++-- src/stories/useAnchoredPosition.stories.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/__tests__/behaviors/anchoredPosition.ts b/src/__tests__/behaviors/anchoredPosition.ts index f72431d49a9..d2749a06bc8 100644 --- a/src/__tests__/behaviors/anchoredPosition.ts +++ b/src/__tests__/behaviors/anchoredPosition.ts @@ -159,7 +159,7 @@ describe('getAnchoredPosition', () => { let top = 0 let left = 0 - settings.align = 'first' + settings.align = 'start' ;({top, left} = getAnchoredPosition(float, anchor, settings)) expect(top).toEqual(234) // anchorRect.top + anchorRect.height + (settings.anchorOffset ?? 4) - parentRect.top expect(left).toEqual(280) // anchorRect.left + (settings.alignmentOffset ?? 0) - parentRect.left @@ -172,7 +172,7 @@ describe('getAnchoredPosition', () => { // anchorRect.left + anchorRect.width / 2 - floatingRect.width / 2 + (settings.anchorOffset ?? 0) - parentRect.left expect(left).toEqual(255) - settings.align = 'last' + settings.align = 'end' ;({top, left} = getAnchoredPosition(float, anchor, settings)) // anchorRect.top + anchorRect.height + (settings.anchorOffset ?? 4) - parentRect.top diff --git a/src/stories/useAnchoredPosition.stories.tsx b/src/stories/useAnchoredPosition.stories.tsx index e6e13afa677..1cd21b7a41a 100644 --- a/src/stories/useAnchoredPosition.stories.tsx +++ b/src/stories/useAnchoredPosition.stories.tsx @@ -191,7 +191,7 @@ export const WithPortal = () => { const {floatingElementRef, anchorElementRef, position} = useAnchoredPosition( { side: 'outside-bottom', - align: 'first' + align: 'start' }, [showMenu] ) From 4f8fc04c98d166bcf87176e9b835369934b7a76e Mon Sep 17 00:00:00 2001 From: Trevor Gau Date: Tue, 2 Mar 2021 15:34:41 -0500 Subject: [PATCH 20/20] Doc formatting. --- docs/content/anchoredPosition.mdx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/content/anchoredPosition.mdx b/docs/content/anchoredPosition.mdx index 42eb0c78ecb..045d5581435 100644 --- a/docs/content/anchoredPosition.mdx +++ b/docs/content/anchoredPosition.mdx @@ -26,7 +26,7 @@ The returned anchored position calculation is relative to the floating element's With the positions and sizes of the above DOM elements, the algorithm calculates the (x, y) coordinate for the floating element. Then, it checks to see if, based on the floating element's size, if it would overflow the bounds of the container. If it would, it does one of two things: -A) If the overflow happens in the same direction as the anchor side (e.g. side is 'outside-bottom' and the overflowing portion of the floating element is the bottom), try to find a different side, recalculate the position, and check for overflow again. If we check all four sides and don't find one that fits, revert to the bottom side, in hopes that a scrollbar might appear. +A) If the overflow happens in the same direction as the anchor side (e.g. side is `'outside-bottom'` and the overflowing portion of the floating element is the bottom), try to find a different side, recalculate the position, and check for overflow again. If we check all four sides and don't find one that fits, revert to the bottom side, in hopes that a scrollbar might appear. B) Otherwise, adjust the alignment offest so that the floating element can stay inside the container's bounds. For a more in-depth explanation of the positioning settings, see `PositionSettings` below. @@ -61,16 +61,16 @@ The `getAnchoredPosition` function takes the following arguments. | anchorElement | `Element` | | This is an Element that the floating element will be "anchored" to. In other words, the calculated position of the floating element will be based on this element's position and size. | | settings | `PositionSettings` | `{}` | Settings to customize the positioning algorithm. See below for a description of each setting. | -#### PositionSettings +#### PositionSettings interface `PositionSettings` is an object with the following interface | Name | Type | Default | Description | | :- | :- | :-: | :- | -| side | `AnchorSide` | `"outside-bottom"` | Sets the side of the anchor element that the floating element should be pinned to. This side is given by a string starting with either "inside" or "outside", followed by a hyphen, followed by either "top", "right", "bottom", or "left". Additionally, "inside-center" is an allowed value.

The first part of this string, "inside" or "outside", determines whether the floating element should be attempted to be placed "inside" the anchor element or "outside" of it. Using "inside" is useful for making it appear that the anchor _contains_ the floating element, and it can be used for implementing a dialog that is centered on the screen. The "outside" value is more common and can be used for tooltips, popovers, menus, etc.

The second part of this string determines the _edge_ on the anchor element that the floating element will be anchored to. If side is "inside-center", then the floating element will be centered in the X-direction (while `align` is used to position it in the Y-direction). | -| align | `AnchorAlignment` | `"start"` | Determines how the floating element should align with the anchor element. If set to "start", the floating element's first edge (top or left) will align with the anchor element's first edge. If set to "center", the floating element will be centered along the axis of the anchor edge. If set to "end", the floating element's last edge will align with the anchor element's last edge. | +| side | `AnchorSide` | `"outside-bottom"` | Sets the side of the anchor element that the floating element should be pinned to. This side is given by a string starting with either `inside` or `outside`, followed by a hyphen, followed by either `top`, `right`, `bottom`, or `left`. Additionally, `"inside-center"` is an allowed value.

The first part of this string, `inside` or `outside`, determines whether the floating element should be attempted to be placed "inside" the anchor element or "outside" of it. Using `inside` is useful for making it appear that the anchor _contains_ the floating element, and it can be used for implementing a dialog that is centered on the screen. The `outside` value is more common and can be used for tooltips, popovers, menus, etc.

The second part of this string determines the _edge_ on the anchor element that the floating element will be anchored to. If side is `"inside-center"`, then the floating element will be centered in the X-direction (while `align` is used to position it in the Y-direction). | +| align | `AnchorAlignment` | `"start"` | Determines how the floating element should align with the anchor element. If set to `"start"`, the floating element's first edge (top or left) will align with the anchor element's first edge. If set to `"center"`, the floating element will be centered along the axis of the anchor edge. If set to `"end"`, the floating element's last edge will align with the anchor element's last edge. | | anchorOffset | `number` | `4`* | The number of pixels between the anchor edge and the floating element. Positive values move the floating element farther from the anchor element (for outside positioning) or further inside the anchor element (for inside positioning). Negative values have the opposite effect. | -| alignmentOffset | `number` | `4`** | An additional offset, in pixels, to move the floating element from the aligning edge. Positive values move the floating element in the direction of center-alignment. Negative values move the floating element away from center-alignment. When align is "center", positive offsets move the floating element right (top or bottom anchor side) or down (left or right anchor side). | -| allowOutOfBounds | `boolean` | `false` | If false, when the above settings result in rendering the floating element wholly or partially off-screen, attempt to adjust the settings to prevent this. Only applies to "outside" positioning.

First, attempt to flip to the opposite edge of the anchor if the floating element is getting clipped in that direction. If flipping results in a similar clipping, try moving to the adjacent sides.

Once we find a side that does not clip the overlay in its own dimension, check the rest of the sides to see if we need to adjust the alignment offset to fit in other dimensions.

If we try all four sides and get clipped each time, settle for overflowing and use the "bottom" side, since the ability to scroll is most likely in this direction. | +| alignmentOffset | `number` | `4`** | An additional offset, in pixels, to move the floating element from the aligning edge. Positive values move the floating element in the direction of center-alignment. Negative values move the floating element away from center-alignment. When align is `"center"`, positive offsets move the floating element right (top or bottom anchor side) or down (left or right anchor side). | +| allowOutOfBounds | `boolean` | `false` | If false, when the above settings result in rendering the floating element wholly or partially off-screen, attempt to adjust the settings to prevent this. Only applies to `outside` positioning.

First, attempt to flip to the opposite edge of the anchor if the floating element is getting clipped in that direction. If flipping results in a similar clipping, try moving to the adjacent sides.

Once we find a side that does not clip the overlay in its own dimension, check the rest of the sides to see if we need to adjust the alignment offset to fit in other dimensions.

If we try all four sides and get clipped each time, settle for overflowing and use the `bottom` side, since the ability to scroll is most likely in this direction. | \* If `side` is set to `"inside-center"`, this defaults to `0` instead of `4`. @@ -136,10 +136,10 @@ export const UseAnchoredPosition = () => { } ``` -### UseAnchoredPositionSettings +### UseAnchoredPositionSettings interface `UseAnchoredPositionSettings` is an object with an interface that extends `PositionSettings` (see above). Additionally, it adds the following properties: | Name | Type | Default | Description | | :- | :- | :-: | :- | -| floatingElementRef | React.RefObject | undefined | If provided, this will be the ref used to access the element that will be used for the floating element. Its size measurements are needed by the underlying `useAnchoredPosition` behavior. Otherwise, this hook will create the ref for you and return it. In both cases, the ref must be provided to the floating element's JSX. | -| anchorElementRef | React.RefObject | undefined | If provided, this will be the ref used to access the element that will be used for the anchor element. Its position and size measurements are needed by the underlying `useAnchoredPosition` behavior. Otherwise, this hook will create the ref for you and return it. In both cases, the ref must be provided to the anchor element's JSX. | +| floatingElementRef | `React.RefObject` | `undefined` | If provided, this will be the ref used to access the element that will be used for the floating element. Its size measurements are needed by the underlying `useAnchoredPosition` behavior. Otherwise, this hook will create the ref for you and return it. In both cases, the ref must be provided to the floating element's JSX. | +| anchorElementRef | `React.RefObject` | `undefined` | If provided, this will be the ref used to access the element that will be used for the anchor element. Its position and size measurements are needed by the underlying `useAnchoredPosition` behavior. Otherwise, this hook will create the ref for you and return it. In both cases, the ref must be provided to the anchor element's JSX. |