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
5 changes: 5 additions & 0 deletions e2e/components/AnchoredOverlay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ const stories: Array<{
id: 'components-anchoredoverlay-dev--reposition-after-content-grows',
waitForText: 'content with 300px height',
},
{
title: 'Reposition After Content Grows With Outside Top',
id: 'components-anchoredoverlay-dev--reposition-after-content-grows-with-outside-top',
waitForText: 'content with 300px height',
},
{
title: 'Reposition After Content Grows Within Dialog',
id: 'components-anchoredoverlay-dev--reposition-after-content-grows-within-dialog',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,56 @@ export const RepositionAfterContentGrows = () => {
)
}

export const RepositionAfterContentGrowsWithOutsideTop = () => {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(true)

React.useEffect(() => {
if (!open) setLoading(true)
const timer = window.setTimeout(() => {
if (open) setLoading(false)
}, 2000)
return () => window.clearTimeout(timer)
}, [open])

return (
<Stack direction="vertical" justify="space-between" style={{height: 'calc(100vh - 200px)'}}>
<div>
What to expect:
<ul>
<li>The anchored overlay should open above the anchor (outside-top)</li>
<li>After 2000ms, the amount of content in the overlay grows</li>
<li>The overlay should grow upward, staying attached at the bottom to the anchor</li>
<li>Bug: without fix, the overlay briefly flickers downward before repositioning</li>
</ul>
</div>
<AnchoredOverlay
renderAnchor={props => (
<Button {...props} style={{width: 'fit-content'}}>
Button
</Button>
)}
open={open}
onOpen={() => setOpen(true)}
onClose={() => {
setOpen(false)
setLoading(true)
}}
side="outside-top"
>
{loading ? (
<>
<Spinner />
loading for 2000ms
</>
) : (
<div style={{height: '300px'}}>content with 300px height</div>
)}
</AnchoredOverlay>
</Stack>
)
}

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,6 @@ export const RepositionAfterLoading = () => {
const [loading, setLoading] = useState(true)

React.useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!open) setLoading(true)
window.setTimeout(() => {
if (open) {
Expand All @@ -369,7 +368,6 @@ export const RepositionAfterLoading = () => {

React.useEffect(() => {
if (!loading) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setFilteredItems(items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down Expand Up @@ -405,7 +403,6 @@ export const SelectPanelRepositionInsideDialog = () => {
const [loading, setLoading] = useState(true)

React.useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!open) setLoading(true)
window.setTimeout(() => {
if (open) {
Expand All @@ -418,7 +415,6 @@ export const SelectPanelRepositionInsideDialog = () => {

React.useEffect(() => {
if (!loading) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setFilteredItems(items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand All @@ -438,7 +434,6 @@ export const SelectPanelRepositionInsideDialog = () => {
selected={selected}
onSelectedChange={setSelected}
onFilterChange={setFilter}
overlayProps={{anchorSide: 'outside-top'}}
message={filteredItems.length === 0 ? NoResultsMessage(filter) : undefined}
/>
</Stack>
Expand Down Expand Up @@ -624,7 +619,6 @@ export const VirtualizedConsumerSide = () => {
[open],
)

// eslint-disable-next-line react-hooks/incompatible-library
const virtualizer = useVirtualizer({
count: filteredItems.length,
getScrollElement: () => scrollContainer ?? null,
Expand Down
156 changes: 156 additions & 0 deletions packages/react/src/SelectPanel/SelectPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2291,3 +2291,159 @@
expect(lastCall[2]?.displayInViewport).not.toBe(true)
})
})

/**
* Test suite for SelectPanel first-open sizing regression
* Issue: https://github.com/primer/react/issues/1831
*
* On first open with loading state, SelectPanel may render shorter than its content
* and show an unnecessary scrollbar. This occurs because position is calculated before
* items have loaded and rendered, using the spinner/loading state height rather than
* the final content height.
*/
describe('SelectPanel - First-Open Sizing with Loading State', () => {
const items: ItemInput[] = [
{id: '1', text: 'Item 1'},
{id: '2', text: 'Item 2'},
{id: '3', text: 'Item 3'},
{id: '4', text: 'Item 4'},
{id: '5', text: 'Item 5'},
{id: '6', text: 'Item 6'},
{id: '7', text: 'Item 7'},
{id: '8', text: 'Item 8'},
]

const TestComponentWithLoadingDelay = () => {
const [selected, setSelected] = React.useState<ItemInput | undefined>(items[0])
const [open, setOpen] = React.useState(false)
const [loading, setLoading] = React.useState(true)
const [visibleItems, setVisibleItems] = React.useState<ItemInput[]>([])

// Simulate items loading after a delay (typical async data fetch)
React.useEffect(() => {
if (!open) {
setLoading(true)
setVisibleItems([])
}

const timer = setTimeout(() => {
if (open) {
setVisibleItems(items)
setLoading(false)
}
}, 500) // 500ms delay simulates network/async operation

return () => clearTimeout(timer)
}, [open])

return (
<>
<SelectPanel
title="Select item"
open={open}
onOpenChange={setOpen}
loading={loading}
items={visibleItems}
selected={selected}
onSelectedChange={setSelected}
height="large"
/>
<div data-testid="state">
open:{open} loading:{loading} itemsCount:{visibleItems.length}
</div>
</>
)
}

it('should measure overlay height correctly while loading', async () => {
const user = userEvent.setup()
render(<TestComponentWithLoadingDelay />)

const button = screen.getByRole('button', {name: /Select item/i})

Check failure on line 2362 in packages/react/src/SelectPanel/SelectPanel.test.tsx

View workflow job for this annotation

GitHub Actions / test (react-19)

[@primer/react (chromium)] src/SelectPanel/SelectPanel.test.tsx > SelectPanel - First-Open Sizing with Loading State > should measure overlay height correctly while loading

TestingLibraryElementError: Unable to find an accessible element with the role "button" and name `/Select item/i` Here are the accessible roles: button: Name "Item 1": <button aria-expanded="false" aria-haspopup="true" class="prc-Button-ButtonBase-Eb8-K" data-component="Button" data-loading="false" data-size="medium" data-variant="default" id="_r_2ns_" tabindex="0" type="button" /> -------------------------------------------------- Ignored nodes: comments, script, style <body style="" > <div id="__primerPortalRoot__" style="position: absolute; top: 0px; left: 0px; width: 100%;" /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <div> <button aria-expanded="false" aria-haspopup="true" class="prc-Button-ButtonBase-Eb8-K" data-component="Button" data-loading="false" data-size="medium" data-variant="default" id="_r_2ns_" tabindex="0" type="button" > <span class="prc-Button-ButtonContent-KZ5Bz" data-align="center" data-component="buttonContent" > <span class="prc-Button-Label-B1e2-" data-component="text" > Item 1 </span> </span> <span class="prc-Button-Visual-bL-6W prc-Button-VisualWrap-UmDs8" data-component="trailingAction" > <svg aria-hidden="true" class="octicon octicon-triangle-down" data-component="Octicon" display="inline-block" fill="currentColor" focusable="false" height="16" overflow="visible" style="vertical-align: text-bottom;" viewBox="0 0 16 16" width="16" > <path d="m4.427 7.427 3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 7H4.604a.25.25 0 0 0-.177.427Z" /> </svg> </span> </button> <div data-testid="state" > open: loading: itemsCount: 0 </div> </div> </body> ❯ Object.getElementError ../../node_modules/@testing-library/dom/dist/@testing-library/dom.esm.js:339:18 ❯ ../../node_modules/@testing-library/dom/dist/@testing-library/dom.esm.js:1206:24 ❯ ../../node_modules/@testing-library/dom/dist/@testing-library/dom.esm.js:1185:16 ❯ ../../node_modules/@testing-library/dom/dist/@testing-library/dom.esm.js:1228:18 ❯ getByRole src/SelectPanel/SelectPanel.test.tsx:2362:26

Check failure on line 2362 in packages/react/src/SelectPanel/SelectPanel.test.tsx

View workflow job for this annotation

GitHub Actions / test (react-18)

[@primer/react (chromium)] src/SelectPanel/SelectPanel.test.tsx > SelectPanel - First-Open Sizing with Loading State > should measure overlay height correctly while loading

TestingLibraryElementError: Unable to find an accessible element with the role "button" and name `/Select item/i` Here are the accessible roles: button: Name "Item 1": <button aria-expanded="false" aria-haspopup="true" class="prc-Button-ButtonBase-Eb8-K" data-component="Button" data-loading="false" data-size="medium" data-variant="default" id=":r2ns:" tabindex="0" type="button" /> -------------------------------------------------- Ignored nodes: comments, script, style <body style="" > <div id="__primerPortalRoot__" style="position: absolute; top: 0px; left: 0px; width: 100%;" /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <div> <button aria-expanded="false" aria-haspopup="true" class="prc-Button-ButtonBase-Eb8-K" data-component="Button" data-loading="false" data-size="medium" data-variant="default" id=":r2ns:" tabindex="0" type="button" > <span class="prc-Button-ButtonContent-KZ5Bz" data-align="center" data-component="buttonContent" > <span class="prc-Button-Label-B1e2-" data-component="text" > Item 1 </span> </span> <span class="prc-Button-Visual-bL-6W prc-Button-VisualWrap-UmDs8" data-component="trailingAction" > <svg aria-hidden="true" class="octicon octicon-triangle-down" data-component="Octicon" display="inline-block" fill="currentColor" focusable="false" height="16" overflow="visible" style="vertical-align: text-bottom;" viewBox="0 0 16 16" width="16" > <path d="m4.427 7.427 3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 7H4.604a.25.25 0 0 0-.177.427Z" /> </svg> </span> </button> <div data-testid="state" > open: loading: itemsCount: 0 </div> </div> </body> ❯ Object.getElementError ../../node_modules/@testing-library/dom/dist/@testing-library/dom.esm.js:339:18 ❯ ../../node_modules/@testing-library/dom/dist/@testing-library/dom.esm.js:1206:24 ❯ ../../node_modules/@testing-library/dom/dist/@testing-library/dom.esm.js:1185:16 ❯ ../../node_modules/@testing-library/dom/dist/@testing-library/dom.esm.js:1228:18 ❯ getByRole src/SelectPanel/SelectPanel.test.tsx:2362:26

// Open SelectPanel for the first time
await user.click(button)

// Wait for overlay to appear (should show loading spinner)
await waitFor(
() => {
expect(screen.getByText(/^Item 1$/)).toBeInTheDocument()
},
{timeout: 1000},
)

// Get overlay measurements while still loading
const overlay = document.querySelector('[data-testid="overlay"]') as HTMLElement | null
const clientHeightWhileLoading = overlay?.clientHeight
const scrollHeightWhileLoading = overlay?.scrollHeight

// Wait for items to load
await waitFor(
() => {
const state = screen.getByTestId('state')
expect(state).toHaveTextContent('loading:false')
},
{timeout: 1500},
)

// Get overlay measurements after loading
const clientHeightAfterLoading = overlay?.clientHeight
const scrollHeightAfterLoading = overlay?.scrollHeight

// Log measurements for debugging
console.log('First-open sizing measurements:', {
whileLoading: {clientHeight: clientHeightWhileLoading, scrollHeight: scrollHeightWhileLoading},
afterLoading: {clientHeight: clientHeightAfterLoading, scrollHeight: scrollHeightAfterLoading},
})

// The overlay should have enough height to fit all content without scrollbar
if (clientHeightAfterLoading && scrollHeightAfterLoading) {
const hasScrollbar = scrollHeightAfterLoading > clientHeightAfterLoading
expect(hasScrollbar).toBe(false)
}
})

it('should have consistent height between first and second open', async () => {
const user = userEvent.setup()
const {unmount, rerender} = render(<TestComponentWithLoadingDelay />)

const button = screen.getByRole('button', {name: /Select item/i})

Check failure on line 2410 in packages/react/src/SelectPanel/SelectPanel.test.tsx

View workflow job for this annotation

GitHub Actions / test (react-19)

[@primer/react (chromium)] src/SelectPanel/SelectPanel.test.tsx > SelectPanel - First-Open Sizing with Loading State > should have consistent height between first and second open

TestingLibraryElementError: Unable to find an accessible element with the role "button" and name `/Select item/i` Here are the accessible roles: button: Name "Item 1": <button aria-expanded="false" aria-haspopup="true" class="prc-Button-ButtonBase-Eb8-K" data-component="Button" data-loading="false" data-size="medium" data-variant="default" id="_r_2o1_" tabindex="0" type="button" /> -------------------------------------------------- Ignored nodes: comments, script, style <body style="" > <div id="__primerPortalRoot__" style="position: absolute; top: 0px; left: 0px; width: 100%;" /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <div> <button aria-expanded="false" aria-haspopup="true" class="prc-Button-ButtonBase-Eb8-K" data-component="Button" data-loading="false" data-size="medium" data-variant="default" id="_r_2o1_" tabindex="0" type="button" > <span class="prc-Button-ButtonContent-KZ5Bz" data-align="center" data-component="buttonContent" > <span class="prc-Button-Label-B1e2-" data-component="text" > Item 1 </span> </span> <span class="prc-Button-Visual-bL-6W prc-Button-VisualWrap-UmDs8" data-component="trailingAction" > <svg aria-hidden="true" class="octicon octicon-triangle-down" data-component="Octicon" display="inline-block" fill="currentColor" focusable="false" height="16" overflow="visible" style="vertical-align: text-bottom;" viewBox="0 0 16 16" width="16" > <path d="m4.427 7.427 3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 7H4.604a.25.25 0 0 0-.177.427Z" /> </svg> </span> </button> <div data-testid="state" > open: loading: itemsCount: 0 </div> </div> </body> ❯ Object.getElementError ../../node_modules/@testing-library/dom/dist/@testing-library/dom.esm.js:339:18 ❯ ../../node_modules/@testing-library/dom/dist/@testing-library/dom.esm.js:1206:24 ❯ ../../node_modules/@testing-library/dom/dist/@testing-library/dom.esm.js:1185:16 ❯ ../../node_modules/@testing-library/dom/dist/@testing-library/dom.esm.js:1228:18 ❯ getByRole src/SelectPanel/SelectPanel.test.tsx:2410:26

Check failure on line 2410 in packages/react/src/SelectPanel/SelectPanel.test.tsx

View workflow job for this annotation

GitHub Actions / test (react-18)

[@primer/react (chromium)] src/SelectPanel/SelectPanel.test.tsx > SelectPanel - First-Open Sizing with Loading State > should have consistent height between first and second open

TestingLibraryElementError: Unable to find an accessible element with the role "button" and name `/Select item/i` Here are the accessible roles: button: Name "Item 1": <button aria-expanded="false" aria-haspopup="true" class="prc-Button-ButtonBase-Eb8-K" data-component="Button" data-loading="false" data-size="medium" data-variant="default" id=":r2o1:" tabindex="0" type="button" /> -------------------------------------------------- Ignored nodes: comments, script, style <body style="" > <div id="__primerPortalRoot__" style="position: absolute; top: 0px; left: 0px; width: 100%;" /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <live-region /> <div> <button aria-expanded="false" aria-haspopup="true" class="prc-Button-ButtonBase-Eb8-K" data-component="Button" data-loading="false" data-size="medium" data-variant="default" id=":r2o1:" tabindex="0" type="button" > <span class="prc-Button-ButtonContent-KZ5Bz" data-align="center" data-component="buttonContent" > <span class="prc-Button-Label-B1e2-" data-component="text" > Item 1 </span> </span> <span class="prc-Button-Visual-bL-6W prc-Button-VisualWrap-UmDs8" data-component="trailingAction" > <svg aria-hidden="true" class="octicon octicon-triangle-down" data-component="Octicon" display="inline-block" fill="currentColor" focusable="false" height="16" overflow="visible" style="vertical-align: text-bottom;" viewBox="0 0 16 16" width="16" > <path d="m4.427 7.427 3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 7H4.604a.25.25 0 0 0-.177.427Z" /> </svg> </span> </button> <div data-testid="state" > open: loading: itemsCount: 0 </div> </div> </body> ❯ Object.getElementError ../../node_modules/@testing-library/dom/dist/@testing-library/dom.esm.js:339:18 ❯ ../../node_modules/@testing-library/dom/dist/@testing-library/dom.esm.js:1206:24 ❯ ../../node_modules/@testing-library/dom/dist/@testing-library/dom.esm.js:1185:16 ❯ ../../node_modules/@testing-library/dom/dist/@testing-library/dom.esm.js:1228:18 ❯ getByRole src/SelectPanel/SelectPanel.test.tsx:2410:26

// First open
await user.click(button)
await waitFor(() => {
const state = screen.getByTestId('state')
expect(state).toHaveTextContent('loading:false')
})

const overlay1 = document.querySelector('[data-testid="overlay"]')
const height1 = overlay1?.getBoundingClientRect().height

// Close
await user.click(button)
await waitFor(() => {
const state = screen.getByTestId('state')
expect(state).toHaveTextContent('open:false')
})

// Second open (loading state happens again)
await user.click(button)
await waitFor(
() => {
const state = screen.getByTestId('state')
expect(state).toHaveTextContent('loading:false')
},
{timeout: 1500},
)

const overlay2 = document.querySelector('[data-testid="overlay"]')
const height2 = overlay2?.getBoundingClientRect().height

// Heights should be very similar (allowing for minor variations)
if (height1 && height2) {
const difference = Math.abs(height1 - height2)
console.log(`Height consistency check: first=${height1}px, second=${height2}px, diff=${difference}px`)
expect(difference).toBeLessThan(50) // Allow max 50px variance
}
})
})
Loading