From e22314431d82e682d4d71ce25e9280513c28f419 Mon Sep 17 00:00:00 2001 From: Chloe Rice Date: Fri, 12 Jan 2024 13:14:43 -0500 Subject: [PATCH] [PositionedOverlay] Add support for horizontal positioning --- .changeset/tasty-waves-know.md | 5 + .../PositionedOverlay/PositionedOverlay.tsx | 49 +++++- .../tests/PositionedOverlay.test.tsx | 11 +- .../PositionedOverlay/utilities/math.ts | 162 +++++++++++++----- polaris-react/src/utilities/geometry.ts | 12 +- 5 files changed, 190 insertions(+), 49 deletions(-) create mode 100644 .changeset/tasty-waves-know.md diff --git a/.changeset/tasty-waves-know.md b/.changeset/tasty-waves-know.md new file mode 100644 index 00000000000..22f6d5271a1 --- /dev/null +++ b/.changeset/tasty-waves-know.md @@ -0,0 +1,5 @@ +--- +'@shopify/polaris': minor +--- + +Added support for left and right `preferredPosition` of `PositionedOverlay` diff --git a/polaris-react/src/components/PositionedOverlay/PositionedOverlay.tsx b/polaris-react/src/components/PositionedOverlay/PositionedOverlay.tsx index 3eac1769189..9468b008647 100644 --- a/polaris-react/src/components/PositionedOverlay/PositionedOverlay.tsx +++ b/polaris-react/src/components/PositionedOverlay/PositionedOverlay.tsx @@ -17,12 +17,13 @@ import { import type {PreferredPosition, PreferredAlignment} from './utilities/math'; import styles from './PositionedOverlay.module.scss'; -type Positioning = 'above' | 'below'; +type Positioning = 'above' | 'below' | 'left' | 'right'; interface OverlayDetails { left?: number; right?: number; desiredHeight: number; + desiredWidth?: number; positioning: Positioning; measuring: boolean; activatorRect: Rect; @@ -179,6 +180,7 @@ export class PositionedOverlay extends PureComponent< right, positioning, height, + width, activatorRect, chevronOffset, } = this.state; @@ -188,6 +190,7 @@ export class PositionedOverlay extends PureComponent< left, right, desiredHeight: height, + desiredWidth: width ?? undefined, positioning, activatorRect, chevronOffset, @@ -293,6 +296,12 @@ export class PositionedOverlay extends PureComponent< ? getMarginsForNode(this.overlay.firstElementChild as HTMLElement) : {activator: 0, container: 0, horizontal: 0}; + const overlayMinWidth = + this.overlay.firstElementChild && + this.overlay.firstChild instanceof HTMLElement + ? getMinWidthForNode(this.overlay.firstElementChild as HTMLElement) + : 0; + const containerRect = windowRect(); const zIndexForLayer = getZIndexForLayerFromNode(activator); const zIndex = @@ -307,31 +316,54 @@ export class PositionedOverlay extends PureComponent< fixed, topBarOffset, ); - const horizontalPosition = calculateHorizontalPosition( + + const positionedHorizontal = + preferredPosition === 'left' || preferredPosition === 'right'; + + const calculatedHorizontalPosition = calculateHorizontalPosition( activatorRect, overlayRect, containerRect, overlayMargins, preferredAlignment, + scrollableContainerRect, + positionedHorizontal ? preferredPosition : undefined, + overlayMinWidth, ); + const horizontalPosition = + calculatedHorizontalPosition.left ?? + calculatedHorizontalPosition.right; + const chevronOffset = activatorRect.center.x - horizontalPosition + overlayMargins.horizontal * 2; + let width = null; + + if (fullWidth) width = overlayRect.width; + else if (positionedHorizontal) + width = calculatedHorizontalPosition.width; + this.setState( { measuring: false, - activatorRect: getRectForNode(activator), + activatorRect, left: - preferredAlignment !== 'right' ? horizontalPosition : undefined, + (preferredAlignment !== 'right' && !positionedHorizontal) || + calculatedHorizontalPosition.left + ? horizontalPosition + : undefined, right: - preferredAlignment === 'right' ? horizontalPosition : undefined, + (preferredAlignment === 'right' && !positionedHorizontal) || + calculatedHorizontalPosition.right !== undefined + ? horizontalPosition + : undefined, top: lockPosition ? top : verticalPosition.top, lockPosition: Boolean(fixed), height: verticalPosition.height || 0, - width: fullWidth ? overlayRect.width : null, + width, positioning: verticalPosition.positioning as Positioning, outsideScrollableContainer: onScrollOut != null && @@ -362,6 +394,11 @@ function getMarginsForNode(node: HTMLElement) { }; } +function getMinWidthForNode(node: HTMLElement) { + const nodeStyles = window.getComputedStyle(node); + return parseFloat(nodeStyles.minWidth || '0'); +} + function getZIndexForLayerFromNode(node: HTMLElement) { const layerNode = node.closest(layer.selector) || document.body; const zIndex = diff --git a/polaris-react/src/components/PositionedOverlay/tests/PositionedOverlay.test.tsx b/polaris-react/src/components/PositionedOverlay/tests/PositionedOverlay.test.tsx index bf6256aa194..5e0b985f77b 100644 --- a/polaris-react/src/components/PositionedOverlay/tests/PositionedOverlay.test.tsx +++ b/polaris-react/src/components/PositionedOverlay/tests/PositionedOverlay.test.tsx @@ -108,7 +108,7 @@ describe('', () => { ); expect(spy).toHaveBeenCalledWith({ - activatorRect: {height: 0, left: 0, top: 0, width: 0}, + activatorRect: {height: 0, left: 0, top: 0, right: 0, width: 0}, desiredHeight: 0, left: 0, measuring: false, @@ -122,7 +122,7 @@ describe('', () => { mountWithApp(); expect(spy).toHaveBeenCalledWith({ - activatorRect: {height: 0, left: 0, top: 0, width: 0}, + activatorRect: {height: 0, left: 0, top: 0, right: 0, width: 0}, desiredHeight: 0, left: undefined, measuring: true, @@ -141,7 +141,12 @@ describe('', () => { mathModule, 'calculateHorizontalPosition', ); - calculateHorizontalPositionMock.mockReturnValue(250); + + calculateHorizontalPositionMock.mockReturnValue({ + left: 250, + width: null, + }); + getRectForNodeMock = jest.spyOn(geometry, 'getRectForNode'); getRectForNodeMock.mockReturnValue({ x: 100, diff --git a/polaris-react/src/components/PositionedOverlay/utilities/math.ts b/polaris-react/src/components/PositionedOverlay/utilities/math.ts index df4c8ae59be..1524d9acd9a 100644 --- a/polaris-react/src/components/PositionedOverlay/utilities/math.ts +++ b/polaris-react/src/components/PositionedOverlay/utilities/math.ts @@ -1,6 +1,11 @@ import {Rect} from '../../../utilities/geometry'; -export type PreferredPosition = 'above' | 'below' | 'mostSpace'; +export type PreferredPosition = + | 'above' + | 'below' + | 'mostSpace' + | 'right' + | 'left'; export type PreferredAlignment = 'left' | 'center' | 'right'; @@ -41,38 +46,54 @@ export function calculateVerticalPosition( const enoughSpaceFromTopScroll = distanceToTopScroll >= minimumSpaceToScroll; const enoughSpaceFromBottomScroll = distanceToBottomScroll >= minimumSpaceToScroll; - const heightIfBelow = Math.min(spaceBelow, desiredHeight); const heightIfAbove = Math.min(spaceAbove, desiredHeight); + const heightIfBelow = Math.min(spaceBelow, desiredHeight); const containerRectTop = fixed ? 0 : containerRect.top; - const positionIfAbove = { - height: heightIfAbove - verticalMargins, - top: activatorTop + containerRectTop - heightIfAbove, - positioning: 'above', - }; + const positionIfAbove = + preferredPosition === 'right' || preferredPosition === 'left' + ? { + height: heightIfAbove - verticalMargins, + top: activatorBottom + containerRectTop - heightIfAbove, + positioning: 'above', + } + : { + height: heightIfAbove - verticalMargins, + top: activatorTop + containerRectTop - heightIfAbove, + positioning: 'above', + }; + + const positionIfBelow = + preferredPosition === 'right' || preferredPosition === 'left' + ? { + height: heightIfBelow - verticalMargins, + top: activatorTop + containerRectTop, + positioning: 'below', + } + : { + height: heightIfBelow - verticalMargins, + top: activatorBottom + containerRectTop, + positioning: 'below', + }; + + const mostSpaceOnTop = + (enoughSpaceFromTopScroll || + (distanceToTopScroll >= distanceToBottomScroll && + !enoughSpaceFromBottomScroll)) && + (spaceAbove > desiredHeight || spaceAbove > spaceBelow); - const positionIfBelow = { - height: heightIfBelow - verticalMargins, - top: activatorBottom + containerRectTop, - positioning: 'below', - }; + const mostSpaceOnBottom = + (enoughSpaceFromBottomScroll || + (distanceToBottomScroll >= distanceToTopScroll && + !enoughSpaceFromTopScroll)) && + (spaceBelow > desiredHeight || spaceBelow > spaceAbove); if (preferredPosition === 'above') { - return (enoughSpaceFromTopScroll || - (distanceToTopScroll >= distanceToBottomScroll && - !enoughSpaceFromBottomScroll)) && - (spaceAbove > desiredHeight || spaceAbove > spaceBelow) - ? positionIfAbove - : positionIfBelow; + return mostSpaceOnTop ? positionIfAbove : positionIfBelow; } if (preferredPosition === 'below') { - return (enoughSpaceFromBottomScroll || - (distanceToBottomScroll >= distanceToTopScroll && - !enoughSpaceFromTopScroll)) && - (spaceBelow > desiredHeight || spaceBelow > spaceAbove) - ? positionIfBelow - : positionIfAbove; + return mostSpaceOnBottom ? positionIfBelow : positionIfAbove; } if (enoughSpaceFromTopScroll && enoughSpaceFromBottomScroll) { @@ -90,28 +111,91 @@ export function calculateHorizontalPosition( containerRect: Rect, overlayMargins: Margins, preferredAlignment: PreferredAlignment, + _scrollableContainerRect: Rect, + preferredHorizontalPosition?: 'left' | 'right', + overlayMinWidth = 0, ) { - const maximum = containerRect.width - overlayRect.width; + const maximumWidth = containerRect.width - overlayRect.width; + const minimumSurroundingSpace = overlayMargins.horizontal + ? overlayMargins.horizontal + : 16; + + const desiredWidth = overlayRect.width; + const distanceToLeftEdge = activatorRect.left; + const distanceToRightEdge = containerRect.width - activatorRect.right; + const enoughSpaceFromLeftEdge = distanceToLeftEdge >= overlayMinWidth; + const enoughSpaceFromRightEdge = distanceToRightEdge >= overlayMinWidth; + + if (!preferredHorizontalPosition) { + if (preferredAlignment === 'left') { + return { + left: Math.min( + maximumWidth, + Math.max(0, activatorRect.left - minimumSurroundingSpace), + ), + width: null, + }; + } else if (preferredAlignment === 'right') { + return { + left: Math.min( + maximumWidth, + Math.max(0, activatorRect.right - minimumSurroundingSpace), + ), + width: null, + }; + } + } + + if (preferredHorizontalPosition) { + const positionIfRight = activatorRect.left + activatorRect.width; + const positionIfLeft = containerRect.width - activatorRect.left; - if (preferredAlignment === 'left') { - return Math.min( - maximum, - Math.max(0, activatorRect.left - overlayMargins.horizontal), + const widthIfLeft = Math.min( + distanceToLeftEdge - minimumSurroundingSpace, + desiredWidth, ); - } else if (preferredAlignment === 'right') { - const activatorRight = - containerRect.width - (activatorRect.left + activatorRect.width); - return Math.min( - maximum, - Math.max(0, activatorRight - overlayMargins.horizontal), + const widthIfRight = Math.min( + distanceToRightEdge - minimumSurroundingSpace, + desiredWidth, ); + + if (preferredHorizontalPosition === 'right') { + return enoughSpaceFromRightEdge + ? { + left: positionIfRight, + width: + overlayMinWidth && widthIfRight < overlayMinWidth + ? overlayMinWidth + : null, + } + : {right: positionIfLeft, width: null}; + } else { + return enoughSpaceFromLeftEdge + ? { + right: positionIfLeft, + width: + overlayMinWidth && widthIfLeft < overlayMinWidth + ? overlayMinWidth + : null, + } + : { + left: positionIfRight, + width: null, + }; + } } - return Math.min( - maximum, - Math.max(0, activatorRect.center.x - overlayRect.width / 2), - ); + return { + width: null, + left: Math.min( + maximumWidth, + Math.max( + 0, + activatorRect.center.x - overlayRect.width / 2 + containerRect.left, + ), + ), + }; } export function rectIsOutsideOfRect(inner: Rect, outer: Rect) { diff --git a/polaris-react/src/utilities/geometry.ts b/polaris-react/src/utilities/geometry.ts index 9dc77ce17d9..29b29204ebd 100644 --- a/polaris-react/src/utilities/geometry.ts +++ b/polaris-react/src/utilities/geometry.ts @@ -1,6 +1,7 @@ interface RectConfig { top?: number; left?: number; + right?: number; width?: number; height?: number; } @@ -17,12 +18,20 @@ export class Rect { top: number; left: number; + right: number; width: number; height: number; - constructor({top = 0, left = 0, width = 0, height = 0}: RectConfig = {}) { + constructor({ + top = 0, + left = 0, + right = 0, + width = 0, + height = 0, + }: RectConfig = {}) { this.top = top; this.left = left; + this.right = right; this.width = width; this.height = height; } @@ -50,6 +59,7 @@ export function getRectForNode( return new Rect({ top: rect.top, left: rect.left, + right: rect.right, width: rect.width, height: rect.height, });