+ )
+}
diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.module.css b/packages/react/src/AnchoredOverlay/AnchoredOverlay.module.css
index f40e73c6283..bd82698eb49 100644
--- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.module.css
+++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.module.css
@@ -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));
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;
}
diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx
index 4caecfa0682..ca50de82813 100644
--- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx
+++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx
@@ -1,6 +1,6 @@
import {act, createRef, useCallback, useRef, useState} from 'react'
import {describe, expect, it, vi} from 'vitest'
-import {render} from '@testing-library/react'
+import {render, waitFor} from '@testing-library/react'
import {userEvent} from 'vitest/browser'
import {AnchoredOverlay} from '../AnchoredOverlay'
import {Button} from '../Button'
@@ -394,6 +394,115 @@ describe('AnchoredOverlay feature flag specific behavior', () => {
})
})
+describe('AnchoredOverlay CSS anchor positioning viewport handling', () => {
+ it('should set --anchored-overlay-top-override when the overlay would overflow the viewport bottom', async () => {
+ function TestComponent() {
+ const ref = useRef(null)
+ return (
+
+
+
+ {}}
+ onClose={() => {}}
+ renderAnchor={null}
+ anchorRef={ref}
+ side="outside-bottom"
+ >
+ {/* Make the overlay taller than the viewport so it overflows */}
+
tall content
+
+
+
+ )
+ }
+
+ const {baseElement} = render()
+ const overlay = baseElement.querySelector('[data-component="AnchoredOverlay"]') as HTMLElement
+
+ await waitFor(() => {
+ expect(overlay.style.getPropertyValue('--anchored-overlay-top-override')).not.toBe('')
+ })
+
+ // The override should clamp top to keep the bottom edge on screen.
+ const value = overlay.style.getPropertyValue('--anchored-overlay-top-override')
+ expect(value.endsWith('px')).toBe(true)
+ expect(parseFloat(value)).toBeGreaterThanOrEqual(0)
+ })
+
+ it('should not set --anchored-overlay-top-override when the overlay fits in the viewport', async () => {
+ function TestComponent() {
+ const ref = useRef(null)
+ return (
+
+
+
+ {}} onClose={() => {}} renderAnchor={null} anchorRef={ref}>
+
short content
+
+
+
+ )
+ }
+
+ const {baseElement} = render()
+ const overlay = baseElement.querySelector('[data-component="AnchoredOverlay"]') as HTMLElement
+
+ // Wait two frames so the rAF positioning callback has run.
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve(null))))
+
+ expect(overlay.style.getPropertyValue('--anchored-overlay-top-override')).toBe('')
+ })
+
+ it('should set data-side to a suggested side when the overlay overflows both axes', async () => {
+ function TestComponent() {
+ const ref = useRef(null)
+ // Anchor pinned to the bottom-right corner so an outside-bottom + start
+ // overlay overflows both the right and bottom edges of the viewport.
+ return (
+
+
+
+ {}}
+ onClose={() => {}}
+ renderAnchor={null}
+ anchorRef={ref}
+ side="outside-bottom"
+ width="medium"
+ >
+
tall content
+
+
+
+ )
+ }
+
+ const {baseElement} = render()
+ const overlay = baseElement.querySelector('[data-component="AnchoredOverlay"]') as HTMLElement
+
+ await waitFor(() => {
+ // The JS should suggest a flip because the overlay overflows both axes.
+ // In this layout there is enough room to the left of the anchor, so the
+ // suggested side should flip to outside-left.
+ expect(overlay.getAttribute('data-side')).toBe('outside-left')
+ })
+ })
+})
+
describe('AnchoredOverlay anchor element replacement', () => {
it('should re-apply anchor-name to a new anchor DOM element when the overlay reopens', () => {
function TestComponent() {
diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx
index aa5c0818d0a..c7ad63f7cac 100644
--- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx
+++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx
@@ -267,31 +267,56 @@ export const AnchoredOverlay: React.FC {
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')
+ }
})
// Only call showPopover when shouldRenderAsPopover is enabled
@@ -308,7 +333,6 @@ export const AnchoredOverlay: React.FC {
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 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.
+ 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(