-
Notifications
You must be signed in to change notification settings - Fork 661
[CSS Anchor Positioning] Ensure AnchoredOverlay remains in the viewport
#7802
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
base: main
Are you sure you want to change the base?
Changes from all commits
c0d75b6
aa8b979
f68e82b
6770095
d9716f0
f7596e9
c24827c
dd2d1e8
3ad5e6c
4f8a012
f44bdc5
2199b7f
17da3bc
c9c5562
58058e2
570e6bd
018006e
de5ae7f
7e0009b
fab9385
721ed09
8e4ac41
f75ae3e
5d58705
24ce7d7
7065a9e
55e29e9
2f9bd65
ffbc3f2
ef40b1d
00afccf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@primer/react': patch | ||
| --- | ||
|
|
||
| AnchoredOverlay: Ensure overlay fits within viewport by calculating viewport height + width (behind `primer_react_css_anchor_positioning` feature flag) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -54,14 +54,18 @@ | |
|
|
||
| &[data-side='outside-left'] { | ||
| right: anchor(left); | ||
| top: anchor(top); | ||
| /* Falls back to `anchor(top)` when JS hasn't overridden it. JS sets the | ||
| override when the overlay's bottom would overflow the viewport so we | ||
| can lift it up to keep the bottom edge on screen, mirroring the JS | ||
| anchored-positioning code path. */ | ||
| top: var(--anchored-overlay-top-override, anchor(top)); | ||
|
Comment on lines
+57
to
+61
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is some new logic to handle overflows in the y-axis. Mainly to fix https://github.com/github/primer/issues/6648 gracefully. |
||
| margin-right: var(--base-size-4); | ||
| position-try-fallbacks: flip-inline, flip-block, flip-start, --outside-left-to-bottom; | ||
| } | ||
|
|
||
| &[data-side='outside-right'] { | ||
| left: anchor(right); | ||
| top: anchor(top); | ||
| top: var(--anchored-overlay-top-override, anchor(top)); | ||
| margin-left: var(--base-size-4); | ||
| position-try-fallbacks: flip-inline, flip-block, flip-start, --outside-right-to-bottom; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -267,31 +267,56 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr | |
|
|
||
| const popoverId = useId() | ||
| const id = popoverId.replaceAll(':', '_') // popoverId can contain colons which are invalid in CSS custom property names, so we replace them with underscores | ||
| const anchorName = `--anchored-overlay-anchor-${id}` | ||
|
|
||
| // Manage `anchor-name` on the anchor independently of `open`/`width` so a | ||
| // parent re-render that re-runs the positioning effect below doesn't | ||
| // briefly flicker the anchor link off and back on. | ||
|
Comment on lines
+272
to
+274
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The comment explains it, but we add an additional It helps us ensure that unrelated dependency changes don't accidentally add or remove |
||
| useEffect(() => { | ||
| if (!cssAnchorPositioning || !anchorElement) return | ||
| if (anchorElement.style.getPropertyValue('anchor-name')) return | ||
| anchorElement.style.setProperty('anchor-name', anchorName) | ||
| return () => { | ||
| if (anchorElement.style.getPropertyValue('anchor-name') === anchorName) { | ||
| anchorElement.style.removeProperty('anchor-name') | ||
| } | ||
| } | ||
| }, [cssAnchorPositioning, anchorElement, anchorName]) | ||
|
|
||
| const currentOverlay = overlayRef.current | ||
| useEffect(() => { | ||
| if (!cssAnchorPositioning || !anchorElement) return | ||
|
|
||
| // Link the anchor and the overlay (when present) via CSS anchor positioning. | ||
| anchorElement.style.setProperty('anchor-name', `--anchored-overlay-anchor-${id}`) | ||
| const currentOverlay = overlayRef.current | ||
| const resolvedAnchorName = anchorElement.style.getPropertyValue('anchor-name') || anchorName | ||
|
|
||
| let pendingPositionFrame: number | null = null | ||
| if (open && currentOverlay) { | ||
| currentOverlay.style.setProperty('position-anchor', `--anchored-overlay-anchor-${id}`) | ||
| currentOverlay.style.setProperty('position-anchor', resolvedAnchorName) | ||
|
|
||
| // Defer the getBoundingClientRect read into a `requestAnimationFrame` so the style write above | ||
| // does not force a synchronous layout. | ||
| pendingPositionFrame = requestAnimationFrame(() => { | ||
| pendingPositionFrame = null | ||
| const overlayWidth = width ? parseInt(widthMap[width]) : null | ||
| const result = getDefaultPosition(anchorElement, overlayWidth) | ||
| const fallbackWidth = width ? parseInt(widthMap[width]) : parseInt(widthMap.small) | ||
| const result = getDefaultPosition(anchorElement, currentOverlay, fallbackWidth) | ||
|
|
||
| currentOverlay.setAttribute('data-align', result.horizontal) | ||
| if (result.suggestedSide) { | ||
| currentOverlay.setAttribute('data-side', result.suggestedSide) | ||
| } | ||
|
|
||
| // Apply offset only when viewport is too narrow | ||
| const offset = result.horizontal === 'left' ? result.leftOffset : result.rightOffset | ||
| currentOverlay.style.setProperty(`--anchored-overlay-anchor-offset-${result.horizontal}`, `${offset || 0}px`) | ||
|
|
||
| // Set y-axis offset to prevent overflow if needed. | ||
| const settledRect = currentOverlay.getBoundingClientRect() | ||
| const overflowBottom = settledRect.bottom - window.innerHeight | ||
| if (overflowBottom > 0) { | ||
| const clampedTop = Math.max(0, settledRect.top - overflowBottom - 8) | ||
| currentOverlay.style.setProperty('--anchored-overlay-top-override', `${clampedTop}px`) | ||
| } else { | ||
| currentOverlay.style.removeProperty('--anchored-overlay-top-override') | ||
|
TylerJDev marked this conversation as resolved.
Comment on lines
+300
to
+318
|
||
| } | ||
| }) | ||
|
|
||
| // Only call showPopover when shouldRenderAsPopover is enabled | ||
|
|
@@ -308,7 +333,6 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr | |
|
|
||
| return () => { | ||
| if (pendingPositionFrame !== null) cancelAnimationFrame(pendingPositionFrame) | ||
| anchorElement.style.removeProperty('anchor-name') | ||
| // The overlay may no longer be in the DOM at this point, so we need to check for its presence before trying to update it. | ||
| if (currentOverlay) { | ||
| currentOverlay.style.removeProperty('position-anchor') | ||
|
|
@@ -396,27 +420,50 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr | |
|
|
||
| function getDefaultPosition( | ||
| anchorElement: HTMLElement, | ||
| overlayWidth: number | null, | ||
| ): {horizontal: 'left' | 'right'; leftOffset?: number; rightOffset?: number} { | ||
| const rect = anchorElement.getBoundingClientRect() | ||
| overlayElement: HTMLElement, | ||
| fallbackWidth: number, | ||
| ): { | ||
| horizontal: 'left' | 'right' | ||
| leftOffset?: number | ||
| rightOffset?: number | ||
| suggestedSide?: 'outside-left' | 'outside-right' | 'outside-bottom' | ||
| } { | ||
| const anchorRect = anchorElement.getBoundingClientRect() | ||
| const overlayRect = overlayElement.getBoundingClientRect() | ||
| const vw = window.innerWidth | ||
| const viewportMargin = 8 | ||
| const spaceLeft = rect.left | ||
| const spaceRight = vw - rect.right | ||
| const vh = window.innerHeight | ||
| const margin = 8 | ||
| const overlayWidth = overlayRect.width || fallbackWidth | ||
| const spaceLeft = anchorRect.left | ||
| const spaceRight = vw - anchorRect.right | ||
| const horizontal: 'left' | 'right' = spaceLeft > spaceRight ? 'left' : 'right' | ||
|
|
||
| // If there's no explicit overlay width, or either side has enough space | ||
| // to contain the overlay, let CSS position-try-fallbacks handle positioning | ||
| if (!overlayWidth || spaceLeft >= overlayWidth + viewportMargin || spaceRight >= overlayWidth + viewportMargin) { | ||
| return {horizontal} | ||
| // Suggest a flip when the overlay is currently overflowing both axes: | ||
| // prefer the side of the anchor with enough room, otherwise fall back to | ||
| // outside-bottom and let the offsets keep it inside the viewport. | ||
|
Comment on lines
+441
to
+443
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The summary of the changes here are:
|
||
| const overflowsX = overlayRect.right > vw || overlayRect.left < 0 | ||
| const overflowsY = overlayRect.bottom > vh || overlayRect.top < 0 | ||
| let suggestedSide: 'outside-left' | 'outside-right' | 'outside-bottom' | undefined | ||
| if (overflowsX && overflowsY) { | ||
| if (spaceLeft >= overlayWidth + margin) { | ||
| suggestedSide = 'outside-left' | ||
| } else if (spaceRight >= overlayWidth + margin) { | ||
| suggestedSide = 'outside-right' | ||
| } else { | ||
| suggestedSide = 'outside-bottom' | ||
| } | ||
| } | ||
|
|
||
| // If the viewport is too narrow to fit the overlay on either side, calculate offsets to prevent overflow | ||
| // leftOffset is how much to shift the overlay to the right, rightOffset is how much to shift the overlay to the left | ||
| const leftOffset = Math.max(0, overlayWidth - rect.right + viewportMargin) | ||
| const rightOffset = Math.max(0, rect.left + overlayWidth - vw + viewportMargin) | ||
| // If the viewport is too narrow to fit the overlay on either side, calculate offsets to prevent overflow. | ||
| let leftOffset: number | undefined | ||
| let rightOffset: number | undefined | ||
|
|
||
| if (spaceLeft < overlayWidth + margin && spaceRight < overlayWidth + margin) { | ||
| leftOffset = Math.max(0, overlayWidth - anchorRect.right + margin) | ||
| rightOffset = Math.max(0, anchorRect.left + overlayWidth - vw + margin) | ||
| } | ||
|
|
||
| return {horizontal, leftOffset, rightOffset} | ||
| return {horizontal, leftOffset, rightOffset, suggestedSide} | ||
| } | ||
|
|
||
| function assignRef<T>( | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.