Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
80dfbfd
wip: SelectPanel overflow
francinelucca Jan 17, 2025
25920d6
Merge branch 'main' into francinelucca/select-panel-overflow
francinelucca Jan 23, 2025
a47034c
Merge branch 'main' of github.com:primer/react into francinelucca/sel…
francinelucca Feb 3, 2025
b863080
fix(useAnchoredPosition): refine reposition logic
francinelucca Feb 4, 2025
382e4d1
Merge branch 'main' into francinelucca/select-panel-overflow
francinelucca Feb 4, 2025
d792c50
Create silent-cameras-care.md
francinelucca Feb 4, 2025
dcf559d
fix: lint
francinelucca Feb 4, 2025
0834641
merge branch 'francinelucca/select-panel-overflow' of github.com:prim…
francinelucca Feb 4, 2025
ef2930d
test(AnchoredOverlay): update snapshot
francinelucca Feb 4, 2025
59b9ce5
reorganize stories
francinelucca Feb 4, 2025
3e70f11
fix(Overlay): add default max height
francinelucca Feb 4, 2025
ed5fbbe
fix(SelectPanel): revert preventOverflow changes
francinelucca Feb 4, 2025
9a0fb5a
test(vrt): update snapshots
francinelucca Feb 4, 2025
c2f2dcc
Merge branch 'main' into francinelucca/select-panel-overflow
francinelucca Feb 4, 2025
ef73210
Revert "test(vrt): update snapshots"
francinelucca Feb 4, 2025
81601de
Merge branch 'main' into francinelucca/select-panel-overflow
francinelucca Feb 5, 2025
08a9f9c
test(vrt): update snapshots
francinelucca Feb 5, 2025
961eb39
Merge branch 'main' into francinelucca/select-panel-overflow
francinelucca Feb 5, 2025
ed98b1f
fix(useResizeObserver): SSR compatibility
francinelucca Feb 6, 2025
7c812cc
Revert "test(vrt): update snapshots"
francinelucca Feb 6, 2025
87201d2
test(vrt): update snapshots
francinelucca Feb 6, 2025
7701ebd
Merge branch 'main' of github.com:primer/react into francinelucca/sel…
francinelucca Feb 10, 2025
6cb8394
Merge branch 'main' of github.com:primer/react into francinelucca/sel…
francinelucca Feb 13, 2025
5c5f4fc
fix(SelectPanel): fix flashing race condition and cleanup code
francinelucca Feb 13, 2025
971d1a9
docs(AnchoredOverlay): document new pinPosition pro
francinelucca Feb 13, 2025
02b81d4
fix tests
francinelucca Feb 13, 2025
35d58aa
fix tests
francinelucca Feb 13, 2025
d16252b
fix tests
francinelucca Feb 13, 2025
8c1e8cf
test(vrt): update snapshots
francinelucca Feb 13, 2025
7615786
Revert "test(vrt): update snapshots"
francinelucca Feb 14, 2025
dc18438
remove test code
francinelucca Feb 14, 2025
c874e45
Merge branch 'main' into francinelucca/select-panel-overflow
francinelucca Feb 14, 2025
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
5 changes: 5 additions & 0 deletions .changeset/silent-cameras-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": patch
---

fix(SelectPanel): Correctly recalculate position on overflow
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
110 changes: 110 additions & 0 deletions packages/react/src/AnchoredOverlay/AnchoredOverlay.dev.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import type {Meta} from '@storybook/react'
import React, {useState} from 'react'

import {Button} from '../Button'
import {AnchoredOverlay} from '.'
import {Stack} from '../Stack'
import {Dialog, Spinner} from '..'

const meta = {
title: 'Components/AnchoredOverlay/Dev',
component: AnchoredOverlay,
} satisfies Meta<typeof AnchoredOverlay>

export default meta

export const RepositionAfterContentGrows = () => {
const [open, setOpen] = useState(false)

const [loading, setLoading] = useState(true)

React.useEffect(() => {
window.setTimeout(() => {
if (open) setLoading(false)
}, 2000)
}, [open])

return (
<Stack direction="vertical" justify="space-between" style={{height: 'calc(100vh - 200px)'}}>
<div>
What to expect:
<ul>
<li>The anchored overlay should open below the anchor (default position)</li>
<li>After 2000ms, the amount of content in the overlay grows</li>
<li>the overlay should reposition itself above the anchor so that it stays inside the window</li>
</ul>
</div>
<AnchoredOverlay
renderAnchor={props => (
<Button {...props} sx={{width: 'fit-content'}}>
Button
</Button>
)}
open={open}
onOpen={() => setOpen(true)}
onClose={() => {
setOpen(false)
setLoading(true)
}}
>
{loading ? (
<>
<Spinner />
loading for 2000ms
</>
) : (
<div style={{height: '300px'}}>content with 300px height</div>
)}
</AnchoredOverlay>
</Stack>
)
}

export const RepositionAfterContentGrowsWithinDialog = () => {
const [open, setOpen] = useState(false)

const [loading, setLoading] = useState(true)

React.useEffect(() => {
window.setTimeout(() => {
if (open) setLoading(false)
}, 2000)
}, [open])

return (
<Dialog onClose={() => {}}>
<Stack direction="vertical" justify="space-between" style={{height: 'calc(100vh - 300px)'}}>
<div>
What to expect:
<ul>
<li>The anchored overlay should open below the anchor (default position)</li>
<li>After 2000ms, the amount of content in the overlay grows</li>
<li>the overlay should reposition itself above the anchor so that it stays inside the window</li>
</ul>
</div>
<AnchoredOverlay
renderAnchor={props => (
<Button {...props} sx={{width: 'fit-content'}}>
Button
</Button>
)}
open={open}
onOpen={() => setOpen(true)}
onClose={() => {
setOpen(false)
setLoading(true)
}}
>
{loading ? (
<>
<Spinner />
loading for 2000ms
</>
) : (
<div style={{height: '300px'}}>content with 300px height</div>
)}
</AnchoredOverlay>
</Stack>
</Dialog>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,12 @@
"required": false,
"description": "",
"defaultValue": ""
}, {
"name": "pinPosition",
"type": "boolean",
"required": false,
"description": "If true, the overlay will attempt to prevent position shifting when sitting at the top of the anchor.",
"defaultValue": "false"
}
]
}
8 changes: 7 additions & 1 deletion packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ interface AnchoredOverlayBaseProps extends Pick<OverlayProps, 'height' | 'width'
* If `preventOverflow` is `true`, the width of the `Overlay` will not be adjusted.
*/
preventOverflow?: boolean
/**
* If true, the overlay will attempt to prevent position shifting when sitting at the top of the anchor.
*/
pinPosition?: boolean
}

export type AnchoredOverlayProps = AnchoredOverlayBaseProps &
Expand All @@ -112,11 +116,12 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
overlayProps,
focusTrapSettings,
focusZoneSettings,
side = 'outside-bottom',
side = overlayProps?.['anchorSide'] || 'outside-bottom',
align = 'start',
alignmentOffset,
anchorOffset,
className,
pinPosition,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we add this to AnchoredOverlay.docs.json?

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.

Yes! Done!!

preventOverflow = true,
}) => {
const anchorRef = useProvidedRefOrCreate(externalAnchorRef)
Expand Down Expand Up @@ -155,6 +160,7 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
{
anchorElementRef: anchorRef,
floatingElementRef: overlayRef,
pinPosition,
side,
align,
alignmentOffset,
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/Overlay/Overlay.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
width: auto;
min-width: 192px;
height: auto;
max-height: 100vh;
overflow: hidden;
background-color: var(--overlay-bgColor);
border-radius: var(--borderRadius-large);
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/Overlay/Overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ const StyledOverlay = toggleStyledComponent(
min-width: 192px;
max-width: ${props => props.maxWidth && widthMap[props.maxWidth]};
height: ${props => heightMap[props.height || 'auto']};
max-height: ${props => props.maxHeight && heightMap[props.maxHeight]};
max-height: ${props => (props.maxHeight ? heightMap[props.maxHeight] : '100vh')};
Comment thread
francinelucca marked this conversation as resolved.
width: ${props => widthMap[props.width || 'auto']};
border-radius: 12px;
overflow: ${props => (props.overflow ? props.overflow : 'hidden')};
Expand Down
95 changes: 95 additions & 0 deletions packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type {OverlayProps} from '../Overlay'
import {TriangleDownIcon} from '@primer/octicons-react'
import {ActionList} from '../deprecated/ActionList'
import FormControl from '../FormControl'
import {Stack} from '../Stack'
import {Dialog} from '../experimental'

const meta = {
title: 'Components/SelectPanel/Examples',
Expand Down Expand Up @@ -442,3 +444,96 @@ export const ItemsInScope = () => {
</FormControl>
)
}

export const RepositionAfterLoading = () => {
const [selected, setSelected] = React.useState<ItemInput[]>([items[0], items[1]])
const [open, setOpen] = useState(false)
const [filter, setFilter] = React.useState('')
const [filteredItems, setFilteredItems] = React.useState<typeof items>([])

const [loading, setLoading] = useState(true)

React.useEffect(() => {
if (!open) setLoading(true)
window.setTimeout(() => {
if (open) {
setFilteredItems(items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())))
setLoading(false)
}
}, 2000)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])

React.useEffect(() => {
if (!loading) {
setFilteredItems(items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filter])

return (
<>
<Stack direction="vertical" justify="space-between" style={{height: 'calc(100vh - 300px)', width: 'fit-content'}}>
<h1>Reposition panel after loading</h1>
<SelectPanel
loading={loading}
title="Select labels"
placeholderText="Filter Labels"
open={open}
onOpenChange={setOpen}
items={filteredItems}
selected={selected}
onSelectedChange={setSelected}
onFilterChange={setFilter}
/>
</Stack>
</>
)
}

export const SelectPanelRepositionInsideDialog = () => {
const [selected, setSelected] = React.useState<ItemInput[]>([items[0], items[1]])
const [open, setOpen] = useState(false)
const [filter, setFilter] = React.useState('')
const [filteredItems, setFilteredItems] = React.useState<typeof items>([])

const [loading, setLoading] = useState(true)

React.useEffect(() => {
if (!open) setLoading(true)
window.setTimeout(() => {
if (open) {
setFilteredItems(items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())))
setLoading(false)
}
}, 2000)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])

React.useEffect(() => {
if (!loading) {
setFilteredItems(items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filter])

return (
<Dialog title="SelectPanel reposition after loading inside Dialog" onClose={() => {}}>
<Stack direction="vertical" justify="space-between" style={{height: 'calc(100vh - 500px)', width: 'fit-content'}}>
<p>other content</p>
<SelectPanel
loading={loading}
title="Select labels"
placeholderText="Filter Labels"
open={open}
onOpenChange={setOpen}
items={filteredItems}
selected={selected}
onSelectedChange={setSelected}
onFilterChange={setFilter}
overlayProps={{anchorSide: 'outside-top'}}
/>
</Stack>
</Dialog>
)
}
7 changes: 5 additions & 2 deletions packages/react/src/SelectPanel/SelectPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ interface SelectPanelBaseProps {

export type SelectPanelProps = SelectPanelBaseProps &
Omit<FilteredActionListProps, 'selectionVariant'> &
Pick<AnchoredOverlayProps, 'open' | 'height'> &
Pick<AnchoredOverlayProps, 'open' | 'height' | 'width'> &
AnchoredOverlayWrapperAnchorProps &
(SelectPanelSingleSelection | SelectPanelMultiSelection)

Expand Down Expand Up @@ -185,8 +185,9 @@ export function SelectPanel({
sx,
loading,
initialLoadingType = 'spinner',
height,
className,
height,
width,
id,
...listProps
}: SelectPanelProps): JSX.Element {
Expand Down Expand Up @@ -451,7 +452,9 @@ export function SelectPanel({
focusTrapSettings={focusTrapSettings}
focusZoneSettings={focusZoneSettings}
height={height}
width={width}
anchorId={id}
pinPosition={!height}
>
<LiveRegionOutlet />
{usingModernActionList ? null : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ exports[`AnchoredOverlay should render consistently when open 1`] = `
position: absolute;
min-width: 192px;
height: auto;
max-height: 100vh;
width: auto;
border-radius: 12px;
overflow: hidden;
Expand Down
44 changes: 42 additions & 2 deletions packages/react/src/hooks/useAnchoredPosition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import useLayoutEffect from '../utils/useIsomorphicLayoutEffect'
export interface AnchoredPositionHookSettings extends Partial<PositionSettings> {
floatingElementRef?: React.RefObject<Element>
anchorElementRef?: React.RefObject<Element>
pinPosition?: boolean
Comment thread
camertron marked this conversation as resolved.
}

/**
Expand All @@ -30,22 +31,61 @@ export function useAnchoredPosition(
const floatingElementRef = useProvidedRefOrCreate(settings?.floatingElementRef)
const anchorElementRef = useProvidedRefOrCreate(settings?.anchorElementRef)
const [position, setPosition] = React.useState<AnchorPosition | undefined>(undefined)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, setPrevHeight] = React.useState<number | undefined>(undefined)

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.

used to force the height of the selectPanel when filtered and it wants to shrink but it's anchored at the top (we want it to stay at the top and lock the height)


const topPositionChanged = (prevPosition: AnchorPosition | undefined, newPosition: AnchorPosition) => {
return (
prevPosition &&
['outside-top', 'inside-top'].includes(prevPosition.anchorSide) &&
// either the anchor changed or the element is trying to shrink in height
(prevPosition.anchorSide !== newPosition.anchorSide || prevPosition.top < newPosition.top)
)
}

const updateElementHeight = () => {
let heightUpdated = false
setPrevHeight(prevHeight => {
// if the element is trying to shrink in height, restore to old height to prevent it from jumping
if (prevHeight && prevHeight > (floatingElementRef.current?.clientHeight ?? 0)) {
requestAnimationFrame(() => {
;(floatingElementRef.current as HTMLElement).style.height = `${prevHeight}px`
})
heightUpdated = true
}
return prevHeight
})
return heightUpdated
}

const updatePosition = React.useCallback(
() => {
if (floatingElementRef.current instanceof Element && anchorElementRef.current instanceof Element) {
setPosition(getAnchoredPosition(floatingElementRef.current, anchorElementRef.current, settings))
const newPosition = getAnchoredPosition(floatingElementRef.current, anchorElementRef.current, settings)
setPosition(prev => {
if (settings?.pinPosition && topPositionChanged(prev, newPosition)) {
const anchorTop = anchorElementRef.current?.getBoundingClientRect().top ?? 0
const elementStillFitsOnTop = anchorTop > (floatingElementRef.current?.clientHeight ?? 0)

if (elementStillFitsOnTop && updateElementHeight()) {
return prev
}
}
return newPosition
})
} else {
setPosition(undefined)
}
setPrevHeight(floatingElementRef.current?.clientHeight)
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[floatingElementRef, anchorElementRef, ...dependencies],
)

useLayoutEffect(updatePosition, [updatePosition])

useResizeObserver(updatePosition)
useResizeObserver(updatePosition) // watches for changes in window size
useResizeObserver(updatePosition, floatingElementRef as React.RefObject<HTMLElement>) // watches for changes in floating element size

return {
floatingElementRef,
Expand Down
Loading