Skip to content
18 changes: 9 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

83 changes: 72 additions & 11 deletions packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -355,21 +355,25 @@ export const RepositionAfterLoading = () => {

const [loading, setLoading] = useState(true)

const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) setLoading(true)
setOpen(isOpen)
}

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

React.useEffect(() => {
if (!loading) {
// eslint-disable-next-line react-hooks/set-state-in-effect
// eslint-disable-next-line react-hooks/set-state-in-effect -- Updating filtered items based on filter change
setFilteredItems(items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand All @@ -384,7 +388,7 @@ export const RepositionAfterLoading = () => {
title="Select labels"
placeholderText="Filter Labels"
open={open}
onOpenChange={setOpen}
onOpenChange={handleOpenChange}
items={filteredItems}
selected={selected}
onSelectedChange={setSelected}
Expand All @@ -404,21 +408,25 @@ export const SelectPanelRepositionInsideDialog = () => {

const [loading, setLoading] = useState(true)

const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) setLoading(true)
setOpen(isOpen)
}

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

React.useEffect(() => {
if (!loading) {
// eslint-disable-next-line react-hooks/set-state-in-effect
// eslint-disable-next-line react-hooks/set-state-in-effect -- Updating filtered items based on filter change
setFilteredItems(items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand All @@ -433,19 +441,72 @@ export const SelectPanelRepositionInsideDialog = () => {
title="Select labels"
placeholderText="Filter Labels"
open={open}
onOpenChange={setOpen}
onOpenChange={handleOpenChange}
items={filteredItems}
selected={selected}
onSelectedChange={setSelected}
onFilterChange={setFilter}
overlayProps={{anchorSide: 'outside-top'}}
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.

Unrelated change, but good to have here. The reposition story wasn't repositioning at all because we were always opening it above. Fixed the story, it works well.

message={filteredItems.length === 0 ? NoResultsMessage(filter) : undefined}
/>
</Stack>
</Dialog>
)
}

export const AutogrowAfterLoadingWithOutsideTopAnchor = () => {
const autogrowItems = [...items]

const [selected, setSelected] = React.useState<ItemInput[]>([autogrowItems[0], autogrowItems[1]])
const [open, setOpen] = useState(false)
const [filter, setFilter] = React.useState('')
const [filteredItems, setFilteredItems] = React.useState<typeof autogrowItems>([])
const [loading, setLoading] = useState(true)

const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) setLoading(true)
setOpen(isOpen)
}

React.useEffect(() => {
const timer = window.setTimeout(() => {
if (open) {
setFilteredItems(autogrowItems.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())))
setLoading(false)
}
}, 2000)

return () => window.clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])

React.useEffect(() => {
if (!loading) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Updating filtered items based on filter change
setFilteredItems(autogrowItems.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>Autogrow panel after loading with outside-top anchor</h1>
<SelectPanel
loading={loading}
title="Select labels"
placeholderText="Filter Labels"
open={open}
onOpenChange={handleOpenChange}
items={filteredItems}
selected={selected}
onSelectedChange={setSelected}
onFilterChange={setFilter}
overlayProps={{anchorSide: 'outside-top'}}
message={filteredItems.length === 0 ? NoResultsMessage(filter) : undefined}
/>
</Stack>
)
}

export const WithDefaultMessage = () => {
const [selected, setSelected] = useState<ItemInput[]>(items.slice(1, 3))
const [filter, setFilter] = useState('')
Expand Down
152 changes: 152 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,155 @@ describe('SelectPanel displayInViewport prop', () => {
expect(lastCall[2]?.displayInViewport).not.toBe(true)
})
})

/**
* Test suite for SelectPanel first-open sizing regression
* Issue: https://github.com/github/issues/issues/18331
*
* 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) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset loading state for test fixture
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}
onFilterChange={() => {}}
loading={loading}
items={visibleItems}
selected={selected}
onSelectedChange={setSelected}
height="large"
/>
<div data-testid="state">
open:{String(open)} loading:{String(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: /Item 1/i})

// 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 element
const overlay = document.querySelector('[data-testid="overlay"]') as HTMLElement | null

// 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

// 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()
render(<TestComponentWithLoadingDelay />)

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

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

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)
expect(difference).toBeLessThan(50) // Allow max 50px variance
}
})
})
Loading
Loading