import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import CustomSelect from './CustomSelect'; const OPTIONS = [ { value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }, { value: 'cherry', label: 'Cherry' }, ]; describe('CustomSelect', () => { const onChange = vi.fn(); beforeEach(() => { onChange.mockClear(); }); it('FE-COMP-SELECT-001: renders placeholder when no value is selected', () => { render(); expect(screen.getByText('Pick a fruit')).toBeTruthy(); }); it('FE-COMP-SELECT-002: renders the selected option label', () => { render(); expect(screen.getByText('Banana')).toBeTruthy(); }); it('FE-COMP-SELECT-003: clicking trigger opens the dropdown', async () => { const user = userEvent.setup(); render(); const trigger = screen.getByRole('button'); await user.click(trigger); // All options should now be visible in the portal expect(screen.getByText('Apple')).toBeTruthy(); expect(screen.getByText('Banana')).toBeTruthy(); expect(screen.getByText('Cherry')).toBeTruthy(); }); it('FE-COMP-SELECT-004: options are displayed in the dropdown', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button')); expect(screen.getAllByRole('button').length).toBeGreaterThan(1); // trigger + option buttons }); it('FE-COMP-SELECT-005: clicking an option calls onChange with correct value', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button')); // open // Options in dropdown are also buttons const optionBtns = screen.getAllByRole('button'); // Find the Cherry option button (not the trigger which shows placeholder) const cherryBtn = optionBtns.find(b => b.textContent?.includes('Cherry')); await user.click(cherryBtn!); expect(onChange).toHaveBeenCalledWith('cherry'); }); it('FE-COMP-SELECT-006: clicking an option closes the dropdown', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button')); // open const optionBtns = screen.getAllByRole('button'); const appleBtn = optionBtns.find(b => b.textContent?.includes('Apple')); await user.click(appleBtn!); // After selection, only the trigger button remains in DOM expect(screen.getAllByRole('button')).toHaveLength(1); }); it('FE-COMP-SELECT-007: searchable mode filters options by typed text', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button')); // open const searchInput = screen.getByPlaceholderText('...'); await user.type(searchInput, 'ban'); // Only Banana should remain, Apple and Cherry should be filtered out expect(screen.getByText('Banana')).toBeTruthy(); expect(screen.queryByText('Apple')).toBeNull(); expect(screen.queryByText('Cherry')).toBeNull(); }); // #2078 — the panel is portaled to document.body and positioned fixed, so its // scroll chain runs to the viewport, not to the sheet it visually sits in. On a // phone a flick past either end of the list moved the page instead. it('FE-COMP-SELECT-009: the option list keeps its scroll to itself', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button')); const list = screen.getByText('Apple').closest('div[style*="overflow"]') as HTMLElement; expect(list.style.overscrollBehavior).toBe('contain'); }); it('FE-COMP-SELECT-010: it is still a bounded scroller, not a contained page', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button')); // Guards the other half: containment without a height cap would just make the // panel grow off screen. const list = screen.getByText('Apple').closest('div[style*="overflow"]') as HTMLElement; expect(list.style.overflowY).toBe('auto'); expect(Number.parseInt(list.style.maxHeight, 10)).toBeGreaterThan(0); }); // A select squeezed into a narrow flex row still has to offer readable options: the // trigger shortens to share its row with the control beside it, the menu does not. it('FE-COMP-SELECT-011: the menu is as wide as its trigger by default', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button')); const panel = screen.getByText('Apple').closest('div[style*="position: fixed"]') as HTMLElement; expect(panel.style.width).not.toBe('max-content'); expect(panel.style.maxWidth).toBe(''); }); it('FE-COMP-SELECT-012: menuFit content grows the menu to its options, bounded twice', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button')); const panel = screen.getByText('Apple').closest('div[style*="position: fixed"]') as HTMLElement; expect(panel.style.width).toBe('max-content'); // Never narrower than the trigger, never wider than what the window still has. expect(panel.style.minWidth).not.toBe(''); expect(Number.parseInt(panel.style.maxWidth, 10)).toBeGreaterThan(0); }); it('FE-COMP-SELECT-013: a content-fitted menu stops at the edge of its panel', async () => { const user = userEvent.setup(); render(
, ); // jsdom measures everything as zero, so the panel says how wide it is. const panel = screen.getByTestId('panel'); panel.getBoundingClientRect = () => ({ left: 0, right: 180, width: 180, top: 0, bottom: 32, height: 32, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect; await user.click(screen.getByRole('button')); const menu = screen.getByText('Apple').closest('div[style*="position: fixed"]') as HTMLElement; expect(menu.style.maxWidth).toBe('180px'); }); // #2478: the trip share dialog stored String(value) against options keyed by numeric // user ids, so the pick never showed in the trigger although it was made. const USERS = [ { value: 7, label: 'alice' }, { value: 12, label: 'bob' }, ]; it('FE-COMP-SELECT-014: a value held as text finds the option keyed by the same number', async () => { const user = userEvent.setup(); render(); const trigger = screen.getByRole('button'); expect(trigger).toHaveTextContent('bob'); expect(screen.queryByText('Pick a user')).toBeNull(); // The open list marks the same option with its check, and only that one. await user.click(trigger); const menu = within(screen.getByText('alice').closest('div[style*="position: fixed"]') as HTMLElement); expect(menu.getByRole('button', { name: 'bob' }).querySelector('svg')).not.toBeNull(); expect(menu.getByRole('button', { name: 'alice' }).querySelector('svg')).toBeNull(); }); it('FE-COMP-SELECT-015: a numeric value finds the option keyed by the same text', () => { render(); expect(screen.getByRole('button')).toHaveTextContent('March'); }); it('FE-COMP-SELECT-016: a caller holding nothing still sees the placeholder', () => { render(); expect(screen.getByRole('button')).toHaveTextContent('Pick a user'); }); it('FE-COMP-SELECT-017: picking hands back the option value as it is', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button')); await user.click(screen.getByRole('button', { name: 'alice' })); expect(onChange).toHaveBeenCalledWith(7); }); it('FE-COMP-SELECT-008: disabled state prevents the dropdown from opening', async () => { const user = userEvent.setup(); render(); const trigger = screen.getByRole('button'); await user.click(trigger); // Dropdown should not be in the DOM — options remain hidden expect(screen.queryByText('Apple')).toBeNull(); }); it('applies the external label id only to the trigger', () => { render(<>); expect(screen.getByRole('button', { name: 'Fruit' })).toHaveAttribute('id', 'fruit'); expect(document.querySelectorAll('[id="fruit"]')).toHaveLength(1); }); it.each([undefined, 'fruit'])('keeps the explicit accessible name with id %s', id => { render(); const trigger = screen.getByRole('button', { name: 'Choose fruit' }); expect(trigger).toHaveAttribute('aria-label', 'Choose fruit'); expect(Array.from(trigger.attributes).filter(attribute => attribute.name === 'aria-label')).toHaveLength(1); if (id) expect(trigger).toHaveAttribute('id', id); }); it('associates two independent portals without duplicate ids', async () => { const user = userEvent.setup(); const { container } = render(<> ); const first = screen.getByRole('button', { name: 'First fruit' }); const second = screen.getByRole('button', { name: 'Second fruit' }); await user.click(first); const firstMenuId = first.getAttribute('aria-controls')!; expect(container.contains(document.getElementById(firstMenuId))).toBe(false); await user.click(second); const secondMenuId = second.getAttribute('aria-controls')!; expect(firstMenuId).not.toBe(secondMenuId); expect(document.getElementById(secondMenuId)).toHaveAttribute('role', 'group'); const ids = Array.from(document.querySelectorAll('[id]'), element => element.id); expect(new Set(ids).size).toBe(ids.length); await user.click(within(document.getElementById(secondMenuId)!).getByRole('button', { name: 'Cherry' })); expect(onChange).toHaveBeenCalledWith('cherry'); expect(second).toHaveFocus(); expect(second).toHaveAttribute('aria-expanded', 'false'); }); it.each(['{Enter}', ' '])('supports Tab, %s, arrows, selection and Escape', async openingKey => { const user = userEvent.setup(); render(); const trigger = screen.getByRole('button', { name: 'Choose fruit' }); await user.tab(); expect(trigger).toHaveFocus(); await user.keyboard(openingKey); expect(trigger).toHaveAttribute('aria-expanded', 'true'); await user.keyboard('{ArrowDown}{ArrowDown}'); expect(screen.getByRole('button', { name: 'Banana' })).toHaveFocus(); await user.keyboard('{ArrowUp}{Enter}'); expect(onChange).toHaveBeenCalledWith('apple'); expect(trigger).toHaveFocus(); await user.keyboard('{ArrowUp}'); expect(screen.getByRole('button', { name: 'Cherry' })).toHaveFocus(); await user.keyboard('{Escape}'); expect(trigger).toHaveFocus(); expect(trigger).toHaveAttribute('aria-expanded', 'false'); expect(screen.queryByRole('group')).toBeNull(); }); it('keeps searchable focus and supports arrow navigation from the search field', async () => { const user = userEvent.setup(); render(); const trigger = screen.getByRole('button', { name: 'Choose fruit' }); await user.click(trigger); expect(screen.getByPlaceholderText('...')).toHaveFocus(); await user.type(screen.getByPlaceholderText('...'), 'ban'); await user.keyboard('{ArrowDown}{Enter}'); expect(onChange).toHaveBeenCalledWith('banana'); expect(trigger).toHaveFocus(); }); it('keeps compact rendering, icon, badge and disabled keyboard behavior', async () => { const user = userEvent.setup(); render( }]} />); const trigger = screen.getByRole('button', { name: 'Disabled fruit' }); expect(trigger).toBeDisabled(); expect(trigger.style.padding).toBe('8px 12px'); expect(within(trigger).getByText('3')).toBeInTheDocument(); expect(within(trigger).getByTestId('fruit-icon')).toBeInTheDocument(); await user.tab(); await user.keyboard('{Enter}{ArrowDown}'); expect(screen.queryByRole('group')).toBeNull(); expect(onChange).not.toHaveBeenCalled(); }); it.each([ ['{ArrowDown}', 'Apple'], ['{ArrowUp}', 'Cherry'], ])('keyboard opening a searchable menu with %s retains option focus', async (key, option) => { const user = userEvent.setup(); render(); await user.tab(); await user.keyboard(key); expect(screen.getByRole('button', { name: option })).toHaveFocus(); await user.keyboard('{Escape}'); expect(screen.getByRole('button', { name: 'Choose fruit' })).toHaveFocus(); }); });