Skip to content

Commit

Permalink
Merge pull request #78 from primer/siddharth/out-of-bounds-alignment
Browse files Browse the repository at this point in the history
Anchored position: Flip to alternative alignments on overflow
  • Loading branch information
siddharthkp authored Apr 13, 2022
2 parents 0528bd3 + a909c07 commit 4b23459
Show file tree
Hide file tree
Showing 4 changed files with 118 additions and 2 deletions.
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
}
}

0 comments on commit 4b23459

Please sign in to comment.