Skip to content

Commit cdc6973

Browse files
authored
fix(combobox): initialize virtualized options on open (#7443)
1 parent 71ab228 commit cdc6973

2 files changed

Lines changed: 93 additions & 4 deletions

File tree

packages/emcn/src/components/combobox/combobox.dom.test.tsx

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212
import { act, type ReactNode, useState } from 'react'
1313
import { createRoot, type Root } from 'react-dom/client'
14-
import { afterEach, describe, expect, it, vi } from 'vitest'
14+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
1515
import { InsideModalContext } from '../modal/modal'
1616
import { Combobox } from './combobox'
1717

@@ -270,3 +270,92 @@ describe('Combobox pagination', () => {
270270
expect(document.body.textContent).toContain('Showing the first 10,000 options')
271271
})
272272
})
273+
274+
describe('Combobox virtualized options', () => {
275+
const options = Array.from({ length: 250 }, (_, index) => ({
276+
label: `Model ${index}`,
277+
value: `model-${index}`,
278+
}))
279+
280+
beforeEach(() => {
281+
/** JSDOM has no layout; use a fixed viewport and row height without mocking the virtualizer. */
282+
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (
283+
this: HTMLElement
284+
) {
285+
return this.hasAttribute('data-index') ? 34 : 192
286+
})
287+
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(300)
288+
})
289+
290+
describe.each([false, true])('disablePortal=%s', (disablePortal) => {
291+
it.each([99, 100, 250])('renders %i options on first open and reopen', (count) => {
292+
const onChange = vi.fn()
293+
render(
294+
<Combobox
295+
options={options.slice(0, count)}
296+
disablePortal={disablePortal}
297+
onChange={onChange}
298+
/>
299+
)
300+
301+
click(trigger())
302+
303+
expect(trigger('[data-option-index="0"]').textContent).toBe('Model 0')
304+
const renderedCount = document.querySelectorAll('[role="option"]').length
305+
expect(renderedCount).toBeGreaterThan(0)
306+
if (count >= 100) expect(renderedCount).toBeLessThan(count)
307+
308+
click(trigger())
309+
expect(document.querySelector('[role="listbox"]')).toBeNull()
310+
click(trigger())
311+
312+
mouseDown(trigger('[data-option-index="1"]'))
313+
expect(onChange).toHaveBeenCalledWith('model-1')
314+
})
315+
})
316+
317+
it('renders a selected editable model on focus and supports keyboard selection', () => {
318+
const onChange = vi.fn()
319+
render(<Combobox options={options} editable value='model-0' onChange={onChange} />)
320+
321+
const input = trigger('input[role="combobox"]')
322+
act(() => input.focus())
323+
324+
expect(trigger('[data-option-index="0"]').textContent).toBe('Model 0')
325+
press(input, 'ArrowDown')
326+
press(input, 'Enter')
327+
328+
expect(onChange).toHaveBeenCalledWith('model-0')
329+
})
330+
331+
it('renders and selects options after scrolling beyond the initial window', () => {
332+
const onChange = vi.fn()
333+
render(<Combobox options={options} onChange={onChange} />)
334+
click(trigger())
335+
336+
expect(document.querySelector('[data-option-index="249"]')).toBeNull()
337+
const scrollArea = trigger('[role="listbox"]').parentElement
338+
if (!scrollArea) throw new Error('Scroll area was not rendered')
339+
act(() => {
340+
scrollArea.scrollTop = options.length * 34 - 192
341+
scrollArea.dispatchEvent(new Event('scroll'))
342+
})
343+
344+
mouseDown(trigger('[data-option-index="249"]'))
345+
expect(onChange).toHaveBeenCalledWith('model-249')
346+
})
347+
348+
it('restores virtualized options after filtering below the threshold', () => {
349+
render(<Combobox options={options} searchable />)
350+
click(trigger())
351+
const search = trigger('input[placeholder="Search..."]') as HTMLInputElement
352+
353+
type(search, 'Model 249')
354+
expect(document.querySelectorAll('[role="option"]')).toHaveLength(1)
355+
expect(trigger('[role="option"]').textContent).toBe('Model 249')
356+
357+
type(search, '')
358+
expect(trigger('[data-option-index="0"]').textContent).toBe('Model 0')
359+
expect(document.querySelectorAll('[role="option"]').length).toBeLessThan(options.length)
360+
})
361+
})

packages/emcn/src/components/combobox/combobox.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ const Combobox = memo(
285285
)
286286
const searchInputRef = useRef<HTMLInputElement>(null)
287287
const containerRef = useRef<HTMLDivElement>(null)
288-
const scrollAreaRef = useRef<HTMLDivElement>(null)
288+
const [scrollArea, setScrollArea] = useState<HTMLDivElement | null>(null)
289289
const dropdownRef = useRef<HTMLDivElement>(null)
290290
const blurTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null)
291291
const internalInputRef = useRef<HTMLInputElement>(null)
@@ -428,7 +428,7 @@ const Combobox = memo(
428428
!filteredGroups && !showAllOption && filteredOptions.length >= VIRTUALIZE_OPTION_THRESHOLD
429429
const optionVirtualizer = useVirtualizer({
430430
count: virtualizeOptions ? filteredOptions.length : 0,
431-
getScrollElement: () => scrollAreaRef.current,
431+
getScrollElement: () => scrollArea,
432432
estimateSize: () => (size === 'sm' ? 28 : 34),
433433
overscan: 8,
434434
})
@@ -944,7 +944,7 @@ const Combobox = memo(
944944
</div>
945945
)}
946946
<PopoverScrollArea
947-
ref={scrollAreaRef}
947+
ref={setScrollArea}
948948
className='flex-none! p-1'
949949
style={{ maxHeight: `${maxHeight}px` }}
950950
onScroll={(event) => {

0 commit comments

Comments
 (0)