diff --git a/.changeset/social-deer-laugh.md b/.changeset/social-deer-laugh.md new file mode 100644 index 00000000000..013233c02f2 --- /dev/null +++ b/.changeset/social-deer-laugh.md @@ -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) \ No newline at end of file diff --git a/packages/react/src/ActionMenu/ActionMenu.dev.stories.tsx b/packages/react/src/ActionMenu/ActionMenu.dev.stories.tsx index 3a7cb9c18ec..3e074e924ab 100644 --- a/packages/react/src/ActionMenu/ActionMenu.dev.stories.tsx +++ b/packages/react/src/ActionMenu/ActionMenu.dev.stories.tsx @@ -82,3 +82,61 @@ export const AnchorElementReplacement = () => { ) } + +export const RightAlignedWithLargeSubmenus = () => { + const submenuItems = Array.from({length: 30}, (_, i) => `Item ${i + 1}`) + + return ( +
+ + Open menu + + + + + First submenu (outside bottom) + + + + {submenuItems.map(item => ( + alert(`${item} clicked`)}> + {item} + + ))} + + + + + + Second submenu (outside left) + + + + {submenuItems.map(item => ( + alert(`${item} clicked`)}> + {item} + + ))} + + + + + + Third submenu + + + + {submenuItems.map(item => ( + alert(`${item} clicked`)}> + {item} + + ))} + + + + + + +
+ ) +} 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(