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( )} {