diff --git a/packages/emcn/src/components/combobox/combobox.dom.test.tsx b/packages/emcn/src/components/combobox/combobox.dom.test.tsx
index bd9100de247..fcb3e9c2d78 100644
--- a/packages/emcn/src/components/combobox/combobox.dom.test.tsx
+++ b/packages/emcn/src/components/combobox/combobox.dom.test.tsx
@@ -11,7 +11,7 @@
*/
import { act, type ReactNode, useState } from 'react'
import { createRoot, type Root } from 'react-dom/client'
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { InsideModalContext } from '../modal/modal'
import { Combobox } from './combobox'
@@ -270,3 +270,92 @@ describe('Combobox pagination', () => {
expect(document.body.textContent).toContain('Showing the first 10,000 options')
})
})
+
+describe('Combobox virtualized options', () => {
+ const options = Array.from({ length: 250 }, (_, index) => ({
+ label: `Model ${index}`,
+ value: `model-${index}`,
+ }))
+
+ beforeEach(() => {
+ /** JSDOM has no layout; use a fixed viewport and row height without mocking the virtualizer. */
+ vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (
+ this: HTMLElement
+ ) {
+ return this.hasAttribute('data-index') ? 34 : 192
+ })
+ vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(300)
+ })
+
+ describe.each([false, true])('disablePortal=%s', (disablePortal) => {
+ it.each([99, 100, 250])('renders %i options on first open and reopen', (count) => {
+ const onChange = vi.fn()
+ render(
+
+ )
+
+ click(trigger())
+
+ expect(trigger('[data-option-index="0"]').textContent).toBe('Model 0')
+ const renderedCount = document.querySelectorAll('[role="option"]').length
+ expect(renderedCount).toBeGreaterThan(0)
+ if (count >= 100) expect(renderedCount).toBeLessThan(count)
+
+ click(trigger())
+ expect(document.querySelector('[role="listbox"]')).toBeNull()
+ click(trigger())
+
+ mouseDown(trigger('[data-option-index="1"]'))
+ expect(onChange).toHaveBeenCalledWith('model-1')
+ })
+ })
+
+ it('renders a selected editable model on focus and supports keyboard selection', () => {
+ const onChange = vi.fn()
+ render()
+
+ const input = trigger('input[role="combobox"]')
+ act(() => input.focus())
+
+ expect(trigger('[data-option-index="0"]').textContent).toBe('Model 0')
+ press(input, 'ArrowDown')
+ press(input, 'Enter')
+
+ expect(onChange).toHaveBeenCalledWith('model-0')
+ })
+
+ it('renders and selects options after scrolling beyond the initial window', () => {
+ const onChange = vi.fn()
+ render()
+ click(trigger())
+
+ expect(document.querySelector('[data-option-index="249"]')).toBeNull()
+ const scrollArea = trigger('[role="listbox"]').parentElement
+ if (!scrollArea) throw new Error('Scroll area was not rendered')
+ act(() => {
+ scrollArea.scrollTop = options.length * 34 - 192
+ scrollArea.dispatchEvent(new Event('scroll'))
+ })
+
+ mouseDown(trigger('[data-option-index="249"]'))
+ expect(onChange).toHaveBeenCalledWith('model-249')
+ })
+
+ it('restores virtualized options after filtering below the threshold', () => {
+ render()
+ click(trigger())
+ const search = trigger('input[placeholder="Search..."]') as HTMLInputElement
+
+ type(search, 'Model 249')
+ expect(document.querySelectorAll('[role="option"]')).toHaveLength(1)
+ expect(trigger('[role="option"]').textContent).toBe('Model 249')
+
+ type(search, '')
+ expect(trigger('[data-option-index="0"]').textContent).toBe('Model 0')
+ expect(document.querySelectorAll('[role="option"]').length).toBeLessThan(options.length)
+ })
+})
diff --git a/packages/emcn/src/components/combobox/combobox.tsx b/packages/emcn/src/components/combobox/combobox.tsx
index 24ec26bf3dc..b6d47b53650 100644
--- a/packages/emcn/src/components/combobox/combobox.tsx
+++ b/packages/emcn/src/components/combobox/combobox.tsx
@@ -285,7 +285,7 @@ const Combobox = memo(
)
const searchInputRef = useRef(null)
const containerRef = useRef(null)
- const scrollAreaRef = useRef(null)
+ const [scrollArea, setScrollArea] = useState(null)
const dropdownRef = useRef(null)
const blurTimeoutRef = useRef>(null)
const internalInputRef = useRef(null)
@@ -428,7 +428,7 @@ const Combobox = memo(
!filteredGroups && !showAllOption && filteredOptions.length >= VIRTUALIZE_OPTION_THRESHOLD
const optionVirtualizer = useVirtualizer({
count: virtualizeOptions ? filteredOptions.length : 0,
- getScrollElement: () => scrollAreaRef.current,
+ getScrollElement: () => scrollArea,
estimateSize: () => (size === 'sm' ? 28 : 34),
overscan: 8,
})
@@ -944,7 +944,7 @@ const Combobox = memo(
)}
{