Skip to content

Commit

Permalink
[PositionedOverlay] Add support for horizontal positioning
Browse files Browse the repository at this point in the history
  • Loading branch information
chloerice committed Jan 22, 2024
1 parent 4aabf7c commit e223144
Show file tree
Hide file tree
Showing 5 changed files with 190 additions and 49 deletions.
5 changes: 5 additions & 0 deletions .changeset/tasty-waves-know.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/polaris': minor
---

Added support for left and right `preferredPosition` of `PositionedOverlay`
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -179,6 +180,7 @@ export class PositionedOverlay extends PureComponent<
right,
positioning,
height,
width,
activatorRect,
chevronOffset,
} = this.state;
Expand All @@ -188,6 +190,7 @@ export class PositionedOverlay extends PureComponent<
left,
right,
desiredHeight: height,
desiredWidth: width ?? undefined,
positioning,
activatorRect,
chevronOffset,
Expand Down Expand Up @@ -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 =
Expand All @@ -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 &&
Expand Down Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ describe('<PositionedOverlay />', () => {
);

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,
Expand All @@ -122,7 +122,7 @@ describe('<PositionedOverlay />', () => {
mountWithApp(<PositionedOverlay {...mockProps} render={spy} />);

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,
Expand All @@ -141,7 +141,12 @@ describe('<PositionedOverlay />', () => {
mathModule,
'calculateHorizontalPosition',
);
calculateHorizontalPositionMock.mockReturnValue(250);

calculateHorizontalPositionMock.mockReturnValue({
left: 250,
width: null,
});

getRectForNodeMock = jest.spyOn(geometry, 'getRectForNode');
getRectForNodeMock.mockReturnValue({
x: 100,
Expand Down
162 changes: 123 additions & 39 deletions polaris-react/src/components/PositionedOverlay/utilities/math.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
12 changes: 11 additions & 1 deletion polaris-react/src/utilities/geometry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
interface RectConfig {
top?: number;
left?: number;
right?: number;
width?: number;
height?: number;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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,
});
Expand Down

0 comments on commit e223144

Please sign in to comment.