Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@
"@github/relative-time-element": "^4.5.0",
"@github/tab-container-element": "^4.8.2",
"@lit-labs/react": "1.2.1",
"@oddbird/css-anchor-positioning": "^0.9.0",
"@oddbird/popover-polyfill": "^0.5.2",
"@primer/behaviors": "^1.10.2",
"@primer/live-region-element": "^0.7.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import React, {useState, useRef} from 'react'
import {Button} from '../Button'
import {AnchoredOverlay} from '.'
import {Stack} from '../Stack'
import {Dialog, Spinner, ActionList, ActionMenu} from '..'
import {Dialog, Spinner, ActionList, ActionMenu, Text} from '..'

const meta = {
title: 'Components/AnchoredOverlay/Dev',
Expand Down Expand Up @@ -309,3 +309,50 @@ export const WithActionMenu = {
},
},
}

export const SmallViewportRightAligned = {
render: () => {
const [open, setOpen] = useState(false)

return (
<div style={{display: 'flex', justifyContent: 'flex-end'}}>
<AnchoredOverlay
open={open}
onOpen={() => setOpen(true)}
onClose={() => setOpen(false)}
renderAnchor={props => <Button {...props}>Button</Button>}
overlayProps={{
role: 'dialog',
'aria-modal': true,
'aria-label': 'Small viewport positioning test',
style: {minWidth: '320px'},
}}
width="xlarge"
focusZoneSettings={{disabled: true}}
preventOverflow={false}
>
<div style={{padding: '16px', width: '100%', height: '400px'}}>
<Stack gap="condensed">
<Text weight="medium">Overlay content</Text>
<Text>
This overlay is wider than the available space to the left of the anchor. It should reposition to avoid
overflowing the viewport.
</Text>
</Stack>
</div>
</AnchoredOverlay>
</div>
)
},
parameters: {
viewport: {
defaultViewport: 'small',
},
docs: {
description: {
story:
'Tests overlay positioning when the trigger button is right-aligned on a small viewport. The overlay is wider than the space to the left of the anchor.',
},
},
},
}
15 changes: 15 additions & 0 deletions packages/react/src/AnchoredOverlay/AnchoredOverlay.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
flip-inline,
flip-block flip-inline;
position-visibility: anchors-visible;
position-try-order: most-inline-size;
z-index: 100;
position: fixed !important;

Expand All @@ -35,23 +36,37 @@
/* stylelint-disable primer/spacing */
top: calc(anchor(bottom) + var(--base-size-4));
left: anchor(left);
max-height: calc(100dvh - anchor(bottom) - var(--base-size-4));

&[data-align='left'] {
left: auto;
right: calc(anchor(right) - var(--anchored-overlay-anchor-offset-left));
}
}

&[data-side='outside-top'] {
margin-bottom: var(--base-size-4);
bottom: anchor(top);
left: anchor(left);
max-height: calc(anchor(top) - var(--base-size-4));

&[data-align='left'] {
left: auto;
right: anchor(right);
}
}

&[data-side='outside-left'] {
right: anchor(left);
top: anchor(top);
margin-right: var(--base-size-4);
max-height: calc(100dvh - anchor(top));
}

&[data-side='outside-right'] {
left: anchor(right);
top: anchor(top);
margin-left: var(--base-size-4);
max-height: calc(100dvh - anchor(top));
}
}
56 changes: 35 additions & 21 deletions packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {XIcon} from '@primer/octicons-react'
import classes from './AnchoredOverlay.module.css'
import {clsx} from 'clsx'
import {useFeatureFlag} from '../FeatureFlags'
import {widthMap} from '../Overlay/Overlay'

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

const applyAnchorPositioningPolyfill = async () => {
if (typeof window !== 'undefined' && !('anchorName' in document.documentElement.style)) {
try {
await import('@oddbird/css-anchor-positioning')
} catch (e) {
// eslint-disable-next-line no-console
console.warn('Failed to load CSS anchor positioning polyfill:', e)
}
}
}

const defaultVariant = {
regular: 'anchored',
narrow: 'anchored',
Expand Down Expand Up @@ -173,7 +163,9 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
displayCloseButton = true,
closeButtonProps = defaultCloseButtonProps,
}) => {
const cssAnchorPositioning = useFeatureFlag('primer_react_css_anchor_positioning')
const cssAnchorPositioningFlag = useFeatureFlag('primer_react_css_anchor_positioning')
const supportsNativeCSSAnchorPositioning = useRef(false)
const cssAnchorPositioning = cssAnchorPositioningFlag && supportsNativeCSSAnchorPositioning.current
const anchorRef = useProvidedRefOrCreate(externalAnchorRef)
const [overlayRef, updateOverlayRef] = useRenderForcingRef<HTMLDivElement>()
const anchorId = useId(externalAnchorId)
Expand Down Expand Up @@ -232,19 +224,14 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
[overlayRef.current],
)

const hasLoadedAnchorPositioningPolyfill = useRef(false)

useEffect(() => {
supportsNativeCSSAnchorPositioning.current = 'anchorName' in document.documentElement.style

// ensure overlay ref gets cleared when closed, so position can reset between closing/re-opening
if (!open && overlayRef.current) {
updateOverlayRef(null)
}

if (cssAnchorPositioning && !hasLoadedAnchorPositioningPolyfill.current) {
applyAnchorPositioningPolyfill()
hasLoadedAnchorPositioningPolyfill.current = true
}
}, [open, overlayRef, updateOverlayRef, cssAnchorPositioning])
}, [open, overlayRef, updateOverlayRef])

useFocusZone({
containerRef: overlayRef,
Expand Down Expand Up @@ -282,14 +269,26 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr

if (!cssAnchorPositioning || !open || !currentOverlay) return
currentOverlay.style.setProperty('position-anchor', `--anchored-overlay-anchor-${id}`)

const anchorElement = anchorRef.current
if (anchorElement) {
const overlayWidth = width ? parseInt(widthMap[width]) : null
const result = getDefaultPosition(anchorElement, overlayWidth)

if (result) {
currentOverlay.setAttribute('data-align', result.horizontal)
currentOverlay.style.setProperty('--anchored-overlay-anchor-offset-left', `${result.offset}px`)
}
Comment on lines +274 to +281
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was added as a solution for https://github.com/github/primer/issues/6511. We calculate the required offset for the overlay to be where it can have the most space within the viewport.

This does mean we rely on JS + CSS, but this is a pretty lightweight way to do it. We'll only use JS when there isn't enough space for the overlay to exist, otherwise we rely fully on CSS anchor positioning

}

try {
if (!currentOverlay.matches(':popover-open')) {
currentOverlay.showPopover()
}
} catch {
// Ignore if popover is already showing or not supported
}
}, [cssAnchorPositioning, open, overlayElement, id, overlayRef])
}, [cssAnchorPositioning, open, overlayElement, id, overlayRef, anchorRef, width])

const showXIcon = onClose && variant.narrow === 'fullscreen' && displayCloseButton
const XButtonAriaLabelledBy = closeButtonProps['aria-labelledby']
Expand Down Expand Up @@ -365,6 +364,21 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
)
}

function getDefaultPosition(
anchorElement: HTMLElement,
overlayWidth: number | null,
): {horizontal: 'left' | 'right'; offset: number} | null {
const rect = anchorElement.getBoundingClientRect()
const vw = window.innerWidth

if (!overlayWidth || rect.left >= overlayWidth) return null

const horizontal = vw - rect.right >= rect.left ? 'right' : 'left'
const offset = overlayWidth - rect.left

return {horizontal, offset}
}

function assignRef<T>(
ref: React.MutableRefObject<T | null> | ((instance: T | null) => void) | null | undefined,
value: T | null,
Expand Down
3 changes: 1 addition & 2 deletions packages/react/src/Overlay/Overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,7 @@ export const heightMap = {
'fit-content': 'fit-content',
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-useless-assignment
const widthMap = {
export const widthMap = {
small: '256px',
medium: '320px',
large: '480px',
Expand Down
Loading