diff --git a/docs/content/anchoredPosition.mdx b/docs/content/anchoredPosition.mdx new file mode 100644 index 00000000000..045d5581435 --- /dev/null +++ b/docs/content/anchoredPosition.mdx @@ -0,0 +1,145 @@ +--- +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` 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 behavior allow the user to customize several aspects of this calculation. See **PositionSettings** below for a detailed description of these settings. + +### Positioning algorithm + +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 + +See [this CodePen](https://codepen.io/team/GitHub/pen/OJbPKNZ) for a demo of `useAnchoredPosition`. + +### 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` +``` + +### 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 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. | +| 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_](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 + +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 +export const UseAnchoredPosition = () => { + const {floatingElementRef, anchorElementRef, position} = useAnchoredPosition({side: 'outside-bottom', align: 'center'}) + return ( +
+ } + > + Floating element + + }> + Anchor Element + +
+ ) +} +``` + +### 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. | 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/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 diff --git a/src/__tests__/behaviors/anchoredPosition.ts b/src/__tests__/behaviors/anchoredPosition.ts new file mode 100644 index 00000000000..d2749a06bc8 --- /dev/null +++ b/src/__tests__/behaviors/anchoredPosition.ts @@ -0,0 +1,277 @@ +/* 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 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) + + expect(top).toEqual(234) + expect(left).toEqual(280) + }) + + it('returns the correct position for different outside side settings with no overflow', () => { + 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 + + // 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) // 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) // 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) // 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) // 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 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.side = 'inside-bottom' + ;({top, left} = getAnchoredPosition(float, anchor, settings)) + + // anchorRect.top + anchorRect.height - (settings.anchorOffset ?? 4) - floatingRect.height - parentRect.top + expect(top).toEqual(126) + // 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) // 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)) + + // anchorRect.top + (settings.alignmentOffset ?? 4) - parentRect.top + expect(top).toEqual(184) + // 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) // 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) // 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 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 = '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 + + settings.align = 'center' + ;({top, left} = getAnchoredPosition(float, anchor, settings)) + + // anchorRect.top + anchorRect.height + (settings.anchorOffset ?? 4) - parentRect.top + expect(top).toEqual(234) + // anchorRect.left + anchorRect.width / 2 - floatingRect.width / 2 + (settings.anchorOffset ?? 0) - parentRect.left + expect(left).toEqual(255) + + settings.align = 'end' + ;({top, left} = getAnchoredPosition(float, anchor, settings)) + + // anchorRect.top + anchorRect.height + (settings.anchorOffset ?? 4) - parentRect.top + expect(top).toEqual(234) + // 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 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(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 new file mode 100644 index 00000000000..b4ae8e3dd62 --- /dev/null +++ b/src/behaviors/anchoredPosition.ts @@ -0,0 +1,376 @@ +export type AnchorAlignment = 'start' | 'center' | 'end' + +// When prettier supports template literal types... +// export type AnchorSide = `${'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 "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. + */ + align: AnchorAlignment + + /** + * 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 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 + * 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. + */ + 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 `allowOutOfBounds` +// 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 Size { + width: number + height: number +} + +interface Position { + top: number + left: number +} + +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 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 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 + + return pureCalculateAnchoredPosition( + parentViewport, + floatingElement.getBoundingClientRect(), + anchorElement instanceof Element ? anchorElement.getBoundingClientRect() : anchorElement, + getDefaultSettings(settings) + ) +} + +/** + * 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 +} + +// Default settings to position a floating element +const positionDefaults: PositionSettings = { + side: 'outside-bottom', + align: 'start', + + // 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, + + allowOutOfBounds: false +} + +/** + * 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), + allowOutOfBounds: settings.allowOutOfBounds ?? positionDefaults.allowOutOfBounds + } +} + +/** + * 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 + * @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, 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 (!allowOutOfBounds) { + const alternateOrder = alternateOrders[side] + let positionAttempt = 0 + if (alternateOrder) { + let prevSide = side + const containerDimensions = { + 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) + ) { + 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(floatingRect, anchorRect, nextSide, align, 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. + if (pos.top < 0) { + pos.top = 0 + } + 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. + if (alternateOrder && positionAttempt < alternateOrder.length) { + if (pos.top + floatingRect.height > parentRect.height) { + pos.top = parentRect.height - floatingRect.height + } + } + } + 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: AnchorAlignment, + 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 === 'start') { + left = anchorPosition.left + alignmentOffset + } else if (align === 'center') { + left = anchorPosition.left - (elementDimensions.width - anchorPosition.width) / 2 + alignmentOffset + } else if (align === 'end') { + left = anchorRight - elementDimensions.width - alignmentOffset + } + } + + if (side === 'outside-left' || side === 'outside-right') { + if (align === 'start') { + top = anchorPosition.top + alignmentOffset + } else if (align === 'center') { + top = anchorPosition.top - (elementDimensions.height - anchorPosition.height) / 2 + alignmentOffset + } else if (align === 'end') { + 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 === 'start') { + left = anchorPosition.left + alignmentOffset + } else if (align === 'center') { + left = anchorPosition.left - (elementDimensions.width - anchorPosition.width) / 2 + alignmentOffset + } else if (align === 'end') { + left = anchorRight - elementDimensions.width - alignmentOffset + } + } else if (side === 'inside-left' || side === 'inside-right' || side === 'inside-center') { + if (align === 'start') { + top = anchorPosition.top + alignmentOffset + } else if (align === 'center') { + top = anchorPosition.top - (elementDimensions.height - anchorPosition.height) / 2 + alignmentOffset + } else if (align === 'end') { + 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 + } +} diff --git a/src/hooks/useAnchoredPosition.ts b/src/hooks/useAnchoredPosition.ts new file mode 100644 index 00000000000..1267cb82c9f --- /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/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 new file mode 100644 index 00000000000..1cd21b7a41a --- /dev/null +++ b/src/stories/useAnchoredPosition.stories.tsx @@ -0,0 +1,246 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import React from 'react' +import {Meta} from '@storybook/react' + +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', + decorators: [ + // Note: For some reason, if you use , + // the component gets unmounted from the root every time a control changes! + Story => { + 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} + }, + allowOutOfBounds: { + control: {type: 'boolean'} + } + } +} as Meta + +const Float = styled(Position)` + position: absolute; + border: 1px solid ${get('colors.gray.6')}; + border-radius: ${get('radii.2')}; + 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')}; +` + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +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), + allowOutOfBounds: args.allowOutOfBounds ?? undefined + }, + [args] + ) + return ( + + } + > + Anchor Element + + } + > + Floating element + + + ) +} +export const CenteredOnScreen = () => { + 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) + +

+
+
+ ) +} + +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. 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, + 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 = () => { + 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: 'start' + }, + [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.

+
+
+ ) +}