Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(SelectPanel): Correctly recalculate position on overflow #5562

Merged
merged 32 commits into from
Feb 14, 2025
Merged
Show file tree
Hide file tree
Changes from 23 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
Copy link
Member Author

Choose a reason for hiding this comment

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

No idea what these VRT changes are about. From the pictures it looks like the focus outline color is changing but I tried to reproduce locally and have been unable to (this is a dev only story). It also looks like the new outline more closely matches production 🤷‍♀️

Copy link
Contributor

Choose a reason for hiding this comment

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

Ugh yeah this has been happening more lately... maybe b/c of CSS modules??

Copy link
Member Author

Choose a reason for hiding this comment

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

I really don't know, it was failing on both flags (on and off)

Copy link
Member Author

Choose a reason for hiding this comment

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

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>
)
}
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
Contributor

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
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')};
francinelucca marked this conversation as resolved.
Show resolved Hide 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
33 changes: 31 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
camertron marked this conversation as resolved.
Show resolved Hide resolved
}

/**
Expand All @@ -30,22 +31,50 @@ 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
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 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 &&
prev &&
['outside-top', 'inside-top'].includes(prev.anchorSide) &&
(prev.anchorSide !== newPosition.anchorSide || prev.top < newPosition.top)
Copy link
Member Author

@francinelucca francinelucca Feb 4, 2025

Choose a reason for hiding this comment

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

if the selectPanel is anchored to the top and it used to sit higher on the page....

Copy link
Contributor

Choose a reason for hiding this comment

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

Could we wrap this in a function and give it some comments so we know what's going on when we come back to the code in a few months?

Copy link
Member Author

Choose a reason for hiding this comment

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

Done!

) {
const anchorTop = anchorElementRef.current?.getBoundingClientRect().top ?? 0
if (anchorTop > (floatingElementRef.current?.clientHeight ?? 0)) {
Copy link
Member Author

Choose a reason for hiding this comment

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

if the anchorTop is bigger than the selectPanel's height, that means there is plenty of space for the selectPanel to stay at the top

Copy link
Contributor

Choose a reason for hiding this comment

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

Same question: maybe wrap in a function with a nice, intention-revealing name and some comments?

Copy link
Member Author

Choose a reason for hiding this comment

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

Done!

setPrevHeight(prevHeight => {
if (prevHeight && prevHeight > (floatingElementRef.current?.clientHeight ?? 0)) {
Copy link
Member Author

Choose a reason for hiding this comment

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

re-establish the height if it used to be bigger and is now attempting to shrink

requestAnimationFrame(() => {
Copy link
Member Author

Choose a reason for hiding this comment

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

using requestAnimationFrame here to prevent a resizeObserver loop here due to us changing the element's height, but the observer listening for changes in the element...

;(floatingElementRef.current as HTMLElement).style.height = `${prevHeight}px`
})
} else {
prev = newPosition
Copy link
Member Author

Choose a reason for hiding this comment

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

edgecase where the selectPanel is actually growing instead of shrinking, in this case we do want to allow it to recalculate/reposition

}
return prevHeight
})
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
2 changes: 1 addition & 1 deletion packages/react/src/hooks/useResizeObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,5 @@ export function useResizeObserver<T extends HTMLElement>(
}

// eslint-disable-next-line react-hooks/exhaustive-deps
}, [target, ...depsArray])
}, [target?.current, ...depsArray])
Copy link
Member Author

@francinelucca francinelucca Feb 6, 2025

Choose a reason for hiding this comment

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

I know you originally moved line 25 outside of the hook and put the dependency on the targetEl but that was generating SSR issues due to document not being defined, was wondering how you felt about this alternative instead @siddharthkp

}
Loading