Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Anchored position: Flip to alternative alignments on overflow #78

Merged
merged 5 commits into from
Apr 13, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rare-donuts-ring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/behaviors": patch
---

Anchored Position: Add alternative alignments to flip to if there isn't enough space
3 changes: 2 additions & 1 deletion docs/anchored-position.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ Once we have the clipping container, its bounding box is used as the viewport fo
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 offset so that the floating element can stay inside the container's bounds.
B) If the overflow happens in the same direction as the anchor alignment (e.g. align is `'start'` and the overflowing portion of the floating element is the right), try to find a different alignment, recalculate the position, and check for overflow again. If we check all three alignments and don't find one that fits, revert to initial aligment, in hopes that (C) will fix it.
C) Otherwise, adjust the alignment offset 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.

Expand Down
54 changes: 54 additions & 0 deletions src/__tests__/anchored-position.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,4 +291,58 @@ describe('getAnchoredPosition', () => {
// anchorRect.left + anchorRect.width - floatingRect.width - (settings.anchorOffset ?? 4) - parentRect.left
expect(left).toEqual(290)
})

it('properly calculates the position when alignment needs to be flipped', () => {
const parentRect = makeDOMRect(20, 20, 500, 500)
const anchorRect = makeDOMRect(450, 20, 50, 50) // right aligned square, at the edge
const floatingRect = makeDOMRect(NaN, NaN, 100, 50)
const {float, anchor} = createVirtualDOM(parentRect, anchorRect, floatingRect)
const settings: Partial<PositionSettings> = {side: 'outside-bottom', align: 'start'} // but there's not enough room for that

const {top, left, anchorAlign} = getAnchoredPosition(float, anchor, settings)
expect(top).toEqual(54) // anchorRect.top - parentRect.top + anchorRect.height + (settings.anchorOffset ?? 4)

/**
* with align:start, left = anchorRect.left - parentTop.left = 430
* and right = left + floatingRect.width = 530
* which would overflow parentRect.left + parentRect.width = 520
* so it should flip to align:end
*/
expect(anchorAlign).toEqual('end')
// based on align:end, anchorRect.left + anchorRect.width - parentRect.left - floatingRect.width
expect(left).toEqual(380)
})

it('properly calculates the position when only side needs to be flipped', () => {
const parentRect = makeDOMRect(20, 20, 500, 500)
const anchorRect = makeDOMRect(450, 20, 50, 50) // right aligned square, at the edge
const floatingRect = makeDOMRect(NaN, NaN, 100, 50)
const {float, anchor} = createVirtualDOM(parentRect, anchorRect, floatingRect)

// not enough space for specified side or alignment
const settings: Partial<PositionSettings> = {side: 'outside-right', align: 'start'}

const {top, left, anchorSide, anchorAlign} = getAnchoredPosition(float, anchor, settings)
expect(top).toEqual(0) // anchorRect.top - parentRect.top

expect(anchorSide).toEqual('outside-left') // flipped
expect(anchorAlign).toEqual('start') // no need to flip
expect(left).toEqual(326) // anchorRect.left - floatingRect.width - (settings.anchorOffset ?? 4) - parentRect.left
})

it('properly calculates the position when both side and alignment need to be flipped', () => {
const parentRect = makeDOMRect(20, 20, 500, 500)
const anchorRect = makeDOMRect(450, 20, 50, 50) // right aligned square, at the edge
const floatingRect = makeDOMRect(NaN, NaN, 100, 50)
const {float, anchor} = createVirtualDOM(parentRect, anchorRect, floatingRect)

// not enough space for specified side or alignment
const settings: Partial<PositionSettings> = {side: 'outside-top', align: 'start'}

const {top, left, anchorSide, anchorAlign} = getAnchoredPosition(float, anchor, settings)
expect(anchorSide).toEqual('outside-bottom')
expect(anchorAlign).toEqual('end')
expect(top).toEqual(54) // anchorRect.top + anchorRect.height + (settings.anchorOffset ?? 4) - parentRect.top
expect(left).toEqual(380) // anchorRect.left + anchorRect.width - parentRect.left - floatingRect.width
})
})
58 changes: 57 additions & 1 deletion src/anchored-position.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,15 @@ const alternateOrders: Partial<Record<AnchorSide, [AnchorSide, AnchorSide, Ancho
'outside-right': ['outside-left', 'outside-bottom', 'outside-top', 'outside-bottom']
}

// For each alignment, list the order of alternate alignments to try in
// the event that the original position overflows.
// Prefer start or end over center.
const alternateAlignments: Partial<Record<AnchorAlignment, [AnchorAlignment, AnchorAlignment]>> = {
start: ['end', 'center'],
end: ['start', 'center'],
center: ['end', 'start']
}

interface Size {
width: number
height: number
Expand All @@ -113,6 +122,7 @@ export interface AnchorPosition {
top: number
left: number
anchorSide: AnchorSide
anchorAlign: AnchorAlignment
}

interface BoxPosition extends Size, Position {}
Expand Down Expand Up @@ -275,12 +285,14 @@ function pureCalculateAnchoredPosition(

let pos = calculatePosition(floatingRect, anchorRect, side, align, anchorOffset, alignmentOffset)
let anchorSide = side
let anchorAlign = align
pos.top -= relativePosition.top
pos.left -= relativePosition.left

// Handle screen overflow
if (!allowOutOfBounds) {
const alternateOrder = alternateOrders[side]

let positionAttempt = 0
if (alternateOrder) {
let prevSide = side
Expand All @@ -300,6 +312,30 @@ function pureCalculateAnchoredPosition(
anchorSide = nextSide
}
}

// If using alternate anchor side does not stop overflow,
// try using an alternate alignment
const alternateAlignment = alternateAlignments[align]

let alignmentAttempt = 0
if (alternateAlignment) {
let prevAlign = align

// Try all the alternate alignments until one does not overflow
while (
alignmentAttempt < alternateAlignment.length &&
shouldRecalculateAlignment(prevAlign, pos, relativeViewportRect, floatingRect)
) {
const nextAlign = alternateAlignment[alignmentAttempt++]
prevAlign = nextAlign

pos = calculatePosition(floatingRect, anchorRect, anchorSide, nextAlign, anchorOffset, alignmentOffset)
pos.top -= relativePosition.top
pos.left -= relativePosition.left
anchorAlign = nextAlign
}
}

// At this point we've flipped the position if applicable. Now just nudge until it's on-screen.
if (pos.top < relativeViewportRect.top) {
pos.top = relativeViewportRect.top
Expand All @@ -320,7 +356,7 @@ function pureCalculateAnchoredPosition(
}
}

return {...pos, anchorSide}
return {...pos, anchorSide, anchorAlign}
}

/**
Expand Down Expand Up @@ -440,3 +476,23 @@ function shouldRecalculatePosition(
)
}
}

/**
* Determines if there is an overflow
* @param align,
* @param currentPos
* @param containerDimensions
* @param elementDimensions
*/
function shouldRecalculateAlignment(
align: AnchorAlignment,
currentPos: Position,
containerDimensions: BoxPosition,
elementDimensions: Size
) {
if (align === 'end') {
return currentPos.left < containerDimensions.left
} else if (align === 'start' || align === 'center') {
return currentPos.left + elementDimensions.width > containerDimensions.left + containerDimensions.width
}
Copy link

@khiga8 khiga8 Apr 4, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we'll need a different logic for determining whether center overflows. For instance, checking whether the center of floating element and center of anchored element is equivalent. Notably, this logic for determining overflow may need to consider whether anchorSide is top or bottom, rather than left or right.

Here, shouldRecalculateAlignment returns false unexpectedly:
Screenshot of tooltip that is off centered, in the south direction

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oooh that's a great point! I understand what you mean, will play around with it a bit more

Copy link
Member Author

@siddharthkp siddharthkp Apr 11, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to force the above example, does this feel like the correct behavior?

It stays center as long as it can, then switches to end if there isn't enough space. I wasn't able to get it in the same position as your screenshot 😅

Screen.Recording.2022-04-11.at.5.45.21.PM.mov

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notably, this logic for determining overflow may need to consider whether anchorSide is top or bottom, rather than left or right.

Not sure I understand when this breaks. If the anchorSide is right and there isn't enough space, it switches to left before we re-evaluate alignment. 🤔 What scenario am I missing?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with you. I don't think shouldRecalculateAlignment would ever be called for the anchorSide left or right scenario 👍 If I'm understanding correctly, this method is only relevant for anchorSide top or bottom.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep! It's only relevant if anchorSide is top or bottom 👍

}