Skip to content

Commit d7b9313

Browse files
Replace polyfill with useAnchoredPosition (#7725)
Co-authored-by: TylerJDev <26746305+TylerJDev@users.noreply.github.com> Co-authored-by: Siddharth Kshetrapal <siddharthkp@github.com>
1 parent efa7af8 commit d7b9313

10 files changed

Lines changed: 80 additions & 26 deletions

.changeset/eight-eagles-deny.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': minor
3+
---
4+
5+
AnchoredOverlay: Remove polyfill for CSS Anchor Positioning, use primer/behaviors as fallback. Ensure overlays take available space.
Loading
Loading
Loading
-867 Bytes
Loading

packages/react/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,6 @@
7878
"@github/relative-time-element": "^5.0.0",
7979
"@github/tab-container-element": "^4.8.2",
8080
"@lit-labs/react": "1.2.1",
81-
"@oddbird/css-anchor-positioning": "^0.9.0",
8281
"@oddbird/popover-polyfill": "^0.5.2",
8382
"@primer/behaviors": "^1.10.2",
8483
"@primer/live-region-element": "^0.7.1",

packages/react/src/AnchoredOverlay/AnchoredOverlay.module.css

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,31 +27,56 @@
2727
margin: 0;
2828
padding: 0;
2929
border: 0;
30-
max-height: none;
3130
max-width: none;
3231
}
3332

3433
&[data-side='outside-bottom'] {
3534
/* stylelint-disable primer/spacing */
3635
top: calc(anchor(bottom) + var(--base-size-4));
3736
left: anchor(left);
37+
38+
&[data-align='left'] {
39+
left: auto;
40+
right: calc(anchor(right) - var(--anchored-overlay-anchor-offset-left));
41+
}
3842
}
3943

4044
&[data-side='outside-top'] {
4145
margin-bottom: var(--base-size-4);
4246
bottom: anchor(top);
4347
left: anchor(left);
48+
49+
&[data-align='left'] {
50+
left: auto;
51+
right: anchor(right);
52+
}
4453
}
4554

4655
&[data-side='outside-left'] {
4756
right: anchor(left);
4857
top: anchor(top);
4958
margin-right: var(--base-size-4);
59+
position-try-fallbacks: flip-inline, flip-block, flip-start, --outside-left-to-bottom;
5060
}
5161

5262
&[data-side='outside-right'] {
5363
left: anchor(right);
5464
top: anchor(top);
5565
margin-left: var(--base-size-4);
66+
position-try-fallbacks: flip-inline, flip-block, flip-start, --outside-right-to-bottom;
5667
}
5768
}
69+
70+
@position-try --outside-left-to-bottom {
71+
right: anchor(right);
72+
top: calc(anchor(bottom) + var(--base-size-4));
73+
margin: 0;
74+
width: auto;
75+
}
76+
77+
@position-try --outside-right-to-bottom {
78+
left: anchor(left);
79+
top: calc(anchor(bottom) + var(--base-size-4));
80+
margin: 0;
81+
width: auto;
82+
}

packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {XIcon} from '@primer/octicons-react'
1616
import classes from './AnchoredOverlay.module.css'
1717
import {clsx} from 'clsx'
1818
import {useFeatureFlag} from '../FeatureFlags'
19+
import {widthMap} from '../Overlay/Overlay'
1920

2021
interface AnchoredOverlayPropsWithAnchor {
2122
/**
@@ -125,17 +126,6 @@ export type AnchoredOverlayProps = AnchoredOverlayBaseProps &
125126
(AnchoredOverlayPropsWithAnchor | AnchoredOverlayPropsWithoutAnchor) &
126127
Partial<Pick<PositionSettings, 'align' | 'side' | 'anchorOffset' | 'alignmentOffset' | 'displayInViewport'>>
127128

128-
const applyAnchorPositioningPolyfill = async () => {
129-
if (typeof window !== 'undefined' && !('anchorName' in document.documentElement.style)) {
130-
try {
131-
await import('@oddbird/css-anchor-positioning')
132-
} catch (e) {
133-
// eslint-disable-next-line no-console
134-
console.warn('Failed to load CSS anchor positioning polyfill:', e)
135-
}
136-
}
137-
}
138-
139129
const defaultVariant = {
140130
regular: 'anchored',
141131
narrow: 'anchored',
@@ -173,7 +163,9 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
173163
displayCloseButton = true,
174164
closeButtonProps = defaultCloseButtonProps,
175165
}) => {
176-
const cssAnchorPositioning = useFeatureFlag('primer_react_css_anchor_positioning')
166+
const cssAnchorPositioningFlag = useFeatureFlag('primer_react_css_anchor_positioning')
167+
const supportsNativeCSSAnchorPositioning = useRef(false)
168+
const cssAnchorPositioning = cssAnchorPositioningFlag && supportsNativeCSSAnchorPositioning.current
177169
const anchorRef = useProvidedRefOrCreate(externalAnchorRef)
178170
const [overlayRef, updateOverlayRef] = useRenderForcingRef<HTMLDivElement>()
179171
const anchorId = useId(externalAnchorId)
@@ -232,19 +224,14 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
232224
[overlayRef.current],
233225
)
234226

235-
const hasLoadedAnchorPositioningPolyfill = useRef(false)
236-
237227
useEffect(() => {
228+
supportsNativeCSSAnchorPositioning.current = 'anchorName' in document.documentElement.style
229+
238230
// ensure overlay ref gets cleared when closed, so position can reset between closing/re-opening
239231
if (!open && overlayRef.current) {
240232
updateOverlayRef(null)
241233
}
242-
243-
if (cssAnchorPositioning && !hasLoadedAnchorPositioningPolyfill.current) {
244-
applyAnchorPositioningPolyfill()
245-
hasLoadedAnchorPositioningPolyfill.current = true
246-
}
247-
}, [open, overlayRef, updateOverlayRef, cssAnchorPositioning])
234+
}, [open, overlayRef, updateOverlayRef])
248235

249236
useFocusZone({
250237
containerRef: overlayRef,
@@ -282,14 +269,27 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
282269

283270
if (!cssAnchorPositioning || !open || !currentOverlay) return
284271
currentOverlay.style.setProperty('position-anchor', `--anchored-overlay-anchor-${id}`)
272+
273+
const anchorElement = anchorRef.current
274+
if (anchorElement) {
275+
const overlayWidth = width ? parseInt(widthMap[width]) : null
276+
const result = getDefaultPosition(anchorElement, overlayWidth)
277+
278+
currentOverlay.setAttribute('data-align', result.horizontal)
279+
280+
// Apply offset only when viewport is too narrow
281+
const offset = result.horizontal === 'left' ? result.leftOffset : result.rightOffset
282+
currentOverlay.style.setProperty(`--anchored-overlay-anchor-offset-${result.horizontal}`, `${offset || 0}px`)
283+
}
284+
285285
try {
286286
if (!currentOverlay.matches(':popover-open')) {
287287
currentOverlay.showPopover()
288288
}
289289
} catch {
290290
// Ignore if popover is already showing or not supported
291291
}
292-
}, [cssAnchorPositioning, open, overlayElement, id, overlayRef])
292+
}, [cssAnchorPositioning, open, overlayElement, id, overlayRef, anchorRef, width])
293293

294294
const showXIcon = onClose && variant.narrow === 'fullscreen' && displayCloseButton
295295
const XButtonAriaLabelledBy = closeButtonProps['aria-labelledby']
@@ -365,6 +365,31 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
365365
)
366366
}
367367

368+
function getDefaultPosition(
369+
anchorElement: HTMLElement,
370+
overlayWidth: number | null,
371+
): {horizontal: 'left' | 'right'; leftOffset?: number; rightOffset?: number} {
372+
const rect = anchorElement.getBoundingClientRect()
373+
const vw = window.innerWidth
374+
const viewportMargin = 8
375+
const spaceLeft = rect.left
376+
const spaceRight = vw - rect.right
377+
const horizontal: 'left' | 'right' = spaceLeft > spaceRight ? 'left' : 'right'
378+
379+
// If there's no explicit overlay width, or either side has enough space
380+
// to contain the overlay, let CSS position-try-fallbacks handle positioning
381+
if (!overlayWidth || spaceLeft >= overlayWidth + viewportMargin || spaceRight >= overlayWidth + viewportMargin) {
382+
return {horizontal}
383+
}
384+
385+
// If the viewport is too narrow to fit the overlay on either side, calculate offsets to prevent overflow
386+
// leftOffset is how much to shift the overlay to the right, rightOffset is how much to shift the overlay to the left
387+
const leftOffset = Math.max(0, overlayWidth - rect.right + viewportMargin)
388+
const rightOffset = Math.max(0, rect.left + overlayWidth - vw + viewportMargin)
389+
390+
return {horizontal, leftOffset, rightOffset}
391+
}
392+
368393
function assignRef<T>(
369394
ref: React.MutableRefObject<T | null> | ((instance: T | null) => void) | null | undefined,
370395
value: T | null,

packages/react/src/Overlay/Overlay.module.css

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,8 @@
200200
visibility: hidden;
201201
}
202202

203-
&:where([data-responsive='fullscreen']) {
203+
&:where([data-responsive='fullscreen']),
204+
&[data-responsive='fullscreen'][data-anchor-position='true'] {
204205
@media screen and (--viewportRange-narrow) {
205206
position: fixed;
206207
top: 0;

packages/react/src/Overlay/Overlay.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,7 @@ export const heightMap = {
3333
'fit-content': 'fit-content',
3434
}
3535

36-
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-useless-assignment
37-
const widthMap = {
36+
export const widthMap = {
3837
small: '256px',
3938
medium: '320px',
4039
large: '480px',

0 commit comments

Comments
 (0)