Skip to content

Commit 8bd40de

Browse files
ajhenrycolebemis
andauthored
Add fixed position prop to Overlay (#2715)
* feat(Overlay): Add fixed position prop to overlay * tests(Overlay): Update overlay tests and stories * docs(Overlay): trailing comma * feat: Update Overlay to use css props instead of custom position prop * docs: update Overlay docs to include new props * fix: remove unused types Co-authored-by: Cole Bemis <colebemis@github.com> * fix: simplify left check Co-authored-by: Cole Bemis <colebemis@github.com> * test: update test props * fix: formatting * docs(Overlay): add new props to Overlay json docs * Create .changeset/wild-bananas-doubt.md Co-authored-by: Cole Bemis <colebemis@github.com>
1 parent 1a19444 commit 8bd40de

File tree

8 files changed

+188
-13
lines changed

8 files changed

+188
-13
lines changed

.changeset/wild-bananas-doubt.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+
Overlay: Add `position`, `right`, and `bottom` props

docs/content/Overlay.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ See the [W3C accessibility recommendations for modals](https://www.w3.org/TR/wai
7676
7777
`Overlay` renders its `children` within a div positioned absolutely within a portal within the default portal root. The overlay will not update its positioning if the portal root's positioning changes (e.g., if the portal root is statically positioned after some DOM element that dynamically resizes). You may consider using the [AnchoredOverlay](/AnchoredOverlay) component or [customizing the portal root](/Portal#customizing-the-portal-root) to achieve different positioning behavior.
7878
79+
The position of the Overlay can be customized by using the `position` prop in conjunction with the `top|left|right|bottom` props.
80+
7981
## Props
8082
8183
<ComponentProps data={data} />

src/Overlay.docs.json

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,29 @@
7272
"name": "top",
7373
"type": "number",
7474
"defaultValue": "0",
75-
"description": "Vertical position of the overlay, relative to its closest positioned ancestor (often its `Portal`)."
75+
"description": "The top CSS property of the Overlay — affects the vertical position."
7676
},
7777
{
7878
"name": "left",
7979
"type": "number",
8080
"defaultValue": "0",
81-
"description": "Horizontal position of the overlay, relative to its closest positioned ancestor (often its `Portal`)."
81+
"description": "The left CSS property of the Overlay — affects the horizontal position."
82+
},
83+
{
84+
"name": "right",
85+
"type": "number",
86+
"description": "The right CSS property of the Overlay — affects the horizontal position."
87+
},
88+
{
89+
"name": "bottom",
90+
"type": "number",
91+
"description": "The bottom CSS property of the Overlay — affects the vertical position."
92+
},
93+
{
94+
"name": "position",
95+
"type": "| 'static' | 'relative' | 'absolute' | 'fixed' | 'sticky'",
96+
"defaultValue": "absolute",
97+
"description": "The position CSS property of the Overlay — sets how the Overlay is positioned relative to its Portal"
8298
},
8399
{
84100
"name": "portalContainerName",

src/Overlay.tsx

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,11 @@ type BaseOverlayProps = {
9494
onEscape: (e: KeyboardEvent) => void
9595
visibility?: 'visible' | 'hidden'
9696
'data-test-id'?: unknown
97-
top?: number
98-
left?: number
97+
position?: React.CSSProperties['position']
98+
top?: React.CSSProperties['top']
99+
left?: React.CSSProperties['left']
100+
right?: React.CSSProperties['right']
101+
bottom?: React.CSSProperties['bottom']
99102
portalContainerName?: string
100103
preventFocusOnOpen?: boolean
101104
role?: AriaRole
@@ -117,8 +120,11 @@ type OwnOverlayProps = Merge<StyledOverlayProps, BaseOverlayProps>
117120
* @param height Sets the height of the `Overlay`, pick from our set list of heights, or pass `auto` to automatically set the height based on the content of the `Overlay`, or pass `initial` to set the height based on the initial content of the `Overlay` (i.e. ignoring content changes). `xsmall` corresponds to `192px`, `small` corresponds to `256px`, `medium` corresponds to `320px`, `large` corresponds to `432px`, `xlarge` corresponds to `600px`.
118121
* @param maxHeight Sets the maximum height of the `Overlay`, pick from our set list of heights. `xsmall` corresponds to `192px`, `small` corresponds to `256px`, `medium` corresponds to `320px`, `large` corresponds to `432px`, `xlarge` corresponds to `600px`.
119122
* @param anchorSide If provided, the Overlay will slide into position from the side of the anchor with a brief animation
120-
* @param top Optional. Vertical position of the overlay, relative to its closest positioned ancestor (often its `Portal`).
121-
* @param left Optional. Horizontal position of the overlay, relative to its closest positioned ancestor (often its `Portal`).
123+
* @param top Optional. Vertical top position of the overlay, relative to its closest positioned ancestor (often its `Portal`).
124+
* @param left Optional. Horizontal left position of the overlay, relative to its closest positioned ancestor (often its `Portal`).
125+
* @param right Optional. Horizontal right position of the overlay, relative to its closest positioned ancestor (often its `Portal`).
126+
* @param bottom Optional. Vertical bottom position of the overlay, relative to its closest positioned ancestor (often its `Portal`).
127+
* @param position Optional. Sets how an element is positioned in a document. Defaults to `absolute` positioning.
122128
* @param portalContainerName Optional. The name of the portal container to render the Overlay into.
123129
*/
124130
const Overlay = React.forwardRef<HTMLDivElement, OwnOverlayProps>(
@@ -135,9 +141,12 @@ const Overlay = React.forwardRef<HTMLDivElement, OwnOverlayProps>(
135141
width = 'auto',
136142
top,
137143
left,
144+
right,
145+
bottom,
138146
anchorSide,
139147
portalContainerName,
140148
preventFocusOnOpen,
149+
position,
141150
...rest
142151
},
143152
forwardedRef,
@@ -180,6 +189,9 @@ const Overlay = React.forwardRef<HTMLDivElement, OwnOverlayProps>(
180189
)
181190
}, [anchorSide, slideAnimationDistance, slideAnimationEasing, visibility])
182191

192+
// To be backwards compatible with the old Overlay, we need to set the left prop if x-position is not specified
193+
const leftPosition: React.CSSProperties = left === undefined && right === undefined ? {left: 0} : {left}
194+
183195
return (
184196
<Portal containerName={portalContainerName}>
185197
<StyledOverlay
@@ -190,8 +202,11 @@ const Overlay = React.forwardRef<HTMLDivElement, OwnOverlayProps>(
190202
ref={overlayRef}
191203
style={
192204
{
193-
top: `${top || 0}px`,
194-
left: `${left || 0}px`,
205+
...leftPosition,
206+
right,
207+
top,
208+
bottom,
209+
position,
195210
'--styled-overlay-visibility': visibility,
196211
} as React.CSSProperties
197212
}

src/__tests__/Overlay.test.tsx

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {axe} from 'jest-axe'
77
import theme from '../theme'
88
import BaseStyles from '../BaseStyles'
99
import {ThemeProvider} from '../ThemeProvider'
10-
import {NestedOverlays, MemexNestedOverlays, MemexIssueOverlay} from '../stories/Overlay.stories'
10+
import {NestedOverlays, MemexNestedOverlays, MemexIssueOverlay, PositionedOverlays} from '../stories/Overlay.stories'
1111

1212
type TestComponentSettings = {
1313
initialFocus?: 'button'
@@ -141,6 +141,60 @@ describe('Overlay', () => {
141141
spy.mockRestore()
142142
})
143143

144+
it('should right align when given `right: 0` and `position: fixed`', async () => {
145+
const spy = jest.spyOn(console, 'log').mockImplementation(message => {
146+
if (!message.startsWith('global handler')) {
147+
throw new Error(
148+
`Expected console.log() to be called with: 'global handler:' but instead it was called with: ${message}`,
149+
)
150+
}
151+
})
152+
153+
const user = userEvent.setup()
154+
const container = render(
155+
<ThemeProvider>
156+
<PositionedOverlays right />
157+
</ThemeProvider>,
158+
)
159+
160+
// open first menu
161+
await user.click(container.getByText('Open right overlay'))
162+
expect(container.getByText('Look! right aligned')).toBeInTheDocument()
163+
164+
const overlay = container.getByText('Look! right aligned').parentElement?.parentElement
165+
166+
expect(overlay).toHaveStyle({position: 'fixed', right: 0})
167+
expect(overlay).not.toHaveStyle({left: 0})
168+
169+
spy.mockRestore()
170+
})
171+
172+
it('should left align when not given position and left props', async () => {
173+
const spy = jest.spyOn(console, 'log').mockImplementation(message => {
174+
if (!message.startsWith('global handler')) {
175+
throw new Error(
176+
`Expected console.log() to be called with: 'global handler:' but instead it was called with: ${message}`,
177+
)
178+
}
179+
})
180+
181+
const user = userEvent.setup()
182+
const container = render(
183+
<ThemeProvider>
184+
<PositionedOverlays />
185+
</ThemeProvider>,
186+
)
187+
188+
// open first menu
189+
await user.click(container.getByText('Open left overlay'))
190+
expect(container.getByText('Look! left aligned')).toBeInTheDocument()
191+
192+
const overlay = container.getByText('Look! left aligned').parentElement?.parentElement
193+
expect(overlay).toHaveStyle({left: 0, position: 'absolute'})
194+
195+
spy.mockRestore()
196+
})
197+
144198
it('memex repro: should only close the dropdown when escape is pressed', async () => {
145199
const user = userEvent.setup()
146200
const container = render(

src/__tests__/__snapshots__/AnchoredOverlay.test.tsx.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ exports[`AnchoredOverlay should render consistently when open 1`] = `
216216
data-focus-trap="active"
217217
height="auto"
218218
role="none"
219-
style="top: 4px; left: 0px; --styled-overlay-visibility: visible;"
219+
style="left: 0px; top: 4px; --styled-overlay-visibility: visible;"
220220
width="auto"
221221
>
222222
<span

src/drafts/InlineAutocomplete/InlineAutocomplete.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import React, {cloneElement, useRef} from 'react'
22
import Box from '../../Box'
33
import Portal from '../../Portal'
44
import {BetterSystemStyleObject} from '../../sx'
5-
import {getAbsoluteCharacterCoordinates} from '../utils/character-coordinates'
65
import {useSyntheticChange} from '../hooks/useSyntheticChange'
6+
import {getAbsoluteCharacterCoordinates} from '../utils/character-coordinates'
77

88
import {ShowSuggestionsEvent, Suggestions, TextInputCompatibleChild, TextInputElement, Trigger} from './types'
99
import {augmentHandler, calculateSuggestionsQuery, getSuggestionValue, requireChildrenToBeInput} from './utils'
@@ -198,8 +198,8 @@ const InlineAutocomplete = ({
198198
inputRef={inputRef}
199199
onCommit={onCommit}
200200
onClose={onHideSuggestions}
201-
top={suggestionsOffset.top}
202-
left={suggestionsOffset.left}
201+
top={suggestionsOffset.top || 0}
202+
left={suggestionsOffset.left || 0}
203203
visible={suggestionsVisible}
204204
tabInsertsSuggestions={tabInsertsSuggestions}
205205
/>

src/stories/Overlay.stories.tsx

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,3 +450,86 @@ export const MemexIssueOverlay = () => {
450450
</>
451451
)
452452
}
453+
454+
export const PositionedOverlays = ({right}: {right?: boolean}) => {
455+
const [isOpen, setIsOpen] = useState(false)
456+
const [direction, setDirection] = useState<'left' | 'right'>(right ? 'right' : 'left')
457+
const buttonRef = useRef<HTMLButtonElement>(null)
458+
const confirmButtonRef = useRef<HTMLButtonElement>(null)
459+
const anchorRef = useRef<HTMLDivElement>(null)
460+
const closeOverlay = () => setIsOpen(false)
461+
return (
462+
<Box ref={anchorRef}>
463+
<Button
464+
ref={buttonRef}
465+
onClick={() => {
466+
setIsOpen(!isOpen)
467+
setDirection('left')
468+
}}
469+
>
470+
Open left overlay
471+
</Button>
472+
<Button
473+
ref={buttonRef}
474+
onClick={() => {
475+
setIsOpen(!isOpen)
476+
setDirection('right')
477+
}}
478+
sx={{
479+
mt: 2,
480+
}}
481+
>
482+
Open right overlay
483+
</Button>
484+
{isOpen ? (
485+
direction === 'left' ? (
486+
<Overlay
487+
initialFocusRef={confirmButtonRef}
488+
returnFocusRef={buttonRef}
489+
ignoreClickRefs={[buttonRef]}
490+
onEscape={closeOverlay}
491+
onClickOutside={closeOverlay}
492+
width="auto"
493+
anchorSide="inside-right"
494+
>
495+
<Box
496+
sx={{
497+
height: '100vh',
498+
width: '500px',
499+
display: 'flex',
500+
justifyContent: 'center',
501+
alignItems: 'center',
502+
}}
503+
>
504+
<Text>Look! left aligned</Text>
505+
</Box>
506+
</Overlay>
507+
) : (
508+
<Overlay
509+
initialFocusRef={confirmButtonRef}
510+
returnFocusRef={buttonRef}
511+
ignoreClickRefs={[buttonRef]}
512+
onEscape={closeOverlay}
513+
onClickOutside={closeOverlay}
514+
width="auto"
515+
anchorSide={'inside-left'}
516+
right={0}
517+
position="fixed"
518+
>
519+
<Box
520+
sx={{
521+
height: '100vh',
522+
width: '500px',
523+
display: 'flex',
524+
justifyContent: 'center',
525+
alignItems: 'center',
526+
}}
527+
>
528+
<Text>Look! right aligned</Text>
529+
</Box>
530+
</Overlay>
531+
)
532+
) : null}
533+
</Box>
534+
)
535+
}

0 commit comments

Comments
 (0)