diff --git a/packages/client/src/components/ControlCombobox.spec.tsx b/packages/client/src/components/ControlCombobox.spec.tsx new file mode 100644 index 0000000000..1a659f0cd8 --- /dev/null +++ b/packages/client/src/components/ControlCombobox.spec.tsx @@ -0,0 +1,199 @@ +import { act, render, screen } from '@testing-library/react'; +import ControlCombobox from './ControlCombobox'; + +type CapturedObserver = { + callback: ResizeObserverCallback; + target: Element | null; + disconnect: jest.Mock; +}; + +const observers: CapturedObserver[] = []; + +class CapturingResizeObserver { + callback: ResizeObserverCallback; + target: Element | null = null; + disconnect = jest.fn(); + + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + observers.push(this); + } + + observe(target: Element) { + this.target = target; + } + + unobserve = jest.fn(); +} + +const originalResizeObserver = window.ResizeObserver; + +beforeEach(() => { + observers.length = 0; + (window as unknown as { ResizeObserver: typeof CapturingResizeObserver }).ResizeObserver = + CapturingResizeObserver; +}); + +afterEach(() => { + (window as unknown as { ResizeObserver: typeof ResizeObserver }).ResizeObserver = + originalResizeObserver; +}); + +const items = [ + { label: 'Option A', value: 'a' }, + { label: 'Option B', value: 'b' }, +]; + +const renderCombobox = (initialButtonWidth: number, isCollapsed = false) => { + const offsetWidthSpy = jest + .spyOn(HTMLElement.prototype, 'offsetWidth', 'get') + .mockReturnValue(initialButtonWidth); + + const utils = render( + undefined} + ariaLabel="Test combobox" + isCollapsed={isCollapsed} + showCarat + />, + ); + + return { ...utils, offsetWidthSpy }; +}; + +const getPopoverWidth = () => { + const popover = document.querySelector('.animate-popover') as HTMLElement | null; + return popover?.style.width ?? null; +}; + +const openPopover = () => { + const trigger = screen.getByRole('combobox'); + act(() => { + trigger.click(); + }); +}; + +describe('ControlCombobox popover sizing', () => { + it('uses the button width measured on mount when layout is stable', () => { + renderCombobox(275); + openPopover(); + expect(getPopoverWidth()).toBe('275px'); + }); + + it('updates the popover width when the trigger resizes after mount (regression: agent select dropdown rendering at narrow width)', () => { + const { offsetWidthSpy } = renderCombobox(26); + openPopover(); + expect(getPopoverWidth()).toBe('26px'); + + const observer = observers[0]; + expect(observer).toBeDefined(); + expect(observer.target).not.toBeNull(); + + offsetWidthSpy.mockReturnValue(275); + + act(() => { + observer.callback( + [ + { + target: observer.target as Element, + contentRect: { width: 275 } as DOMRectReadOnly, + borderBoxSize: [{ inlineSize: 275, blockSize: 36 }], + contentBoxSize: [{ inlineSize: 275, blockSize: 36 }], + devicePixelContentBoxSize: [{ inlineSize: 275, blockSize: 36 }], + } as unknown as ResizeObserverEntry, + ], + observer as unknown as ResizeObserver, + ); + }); + + expect(getPopoverWidth()).toBe('275px'); + }); + + it('disconnects the ResizeObserver on unmount', () => { + const { unmount } = renderCombobox(275); + openPopover(); + const observer = observers[0]; + expect(observer).toBeDefined(); + unmount(); + expect(observer.disconnect).toHaveBeenCalledTimes(1); + }); + + it('does not observe the trigger button when isCollapsed is true', () => { + renderCombobox(275, true); + const triggerObservers = observers.filter( + (o) => (o.target as HTMLElement | null)?.tagName === 'BUTTON', + ); + expect(triggerObservers).toHaveLength(0); + }); + + it('falls back to synchronous offsetWidth when ResizeObserver is unavailable', () => { + (window as unknown as { ResizeObserver: typeof ResizeObserver | undefined }).ResizeObserver = + undefined; + + renderCombobox(275); + openPopover(); + + expect(getPopoverWidth()).toBe('275px'); + const triggerObservers = observers.filter( + (o) => (o.target as HTMLElement | null)?.tagName === 'BUTTON', + ); + expect(triggerObservers).toHaveLength(0); + }); + + it('uses button.offsetWidth when borderBoxSize is unavailable', () => { + const { offsetWidthSpy } = renderCombobox(26); + openPopover(); + expect(getPopoverWidth()).toBe('26px'); + + const observer = observers[0]; + expect(observer).toBeDefined(); + + offsetWidthSpy.mockReturnValue(275); + + act(() => { + observer.callback( + [ + { + target: observer.target as Element, + contentRect: { width: 251 } as DOMRectReadOnly, + borderBoxSize: undefined, + contentBoxSize: undefined, + devicePixelContentBoxSize: undefined, + } as unknown as ResizeObserverEntry, + ], + observer as unknown as ResizeObserver, + ); + }); + + expect(getPopoverWidth()).toBe('275px'); + }); + + it('ignores zero-width resize entries', () => { + renderCombobox(275); + openPopover(); + expect(getPopoverWidth()).toBe('275px'); + + const observer = observers[0]; + expect(observer).toBeDefined(); + + act(() => { + observer.callback( + [ + { + target: observer.target as Element, + contentRect: { width: 0 } as DOMRectReadOnly, + borderBoxSize: [{ inlineSize: 0, blockSize: 0 }], + contentBoxSize: [{ inlineSize: 0, blockSize: 0 }], + devicePixelContentBoxSize: [{ inlineSize: 0, blockSize: 0 }], + } as unknown as ResizeObserverEntry, + ], + observer as unknown as ResizeObserver, + ); + }); + + expect(getPopoverWidth()).toBe('275px'); + }); +}); diff --git a/packages/client/src/components/ControlCombobox.tsx b/packages/client/src/components/ControlCombobox.tsx index f53fc0504e..8c6ca3d8bf 100644 --- a/packages/client/src/components/ControlCombobox.tsx +++ b/packages/client/src/components/ControlCombobox.tsx @@ -80,9 +80,30 @@ function ControlCombobox({ }, [searchValue, items]); useEffect(() => { - if (buttonRef.current && !isCollapsed) { - setButtonWidth(buttonRef.current.offsetWidth); + const button = buttonRef.current; + if (!button || isCollapsed) { + return; } + + setButtonWidth(button.offsetWidth); + + if (typeof ResizeObserver === 'undefined') { + return; + } + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) { + return; + } + const width = entry.borderBoxSize?.[0]?.inlineSize ?? button.offsetWidth; + if (width > 0) { + setButtonWidth(width); + } + }); + + observer.observe(button); + return () => observer.disconnect(); }, [isCollapsed]); const selectIconClassName = cn(