mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
294 lines
14 KiB
TypeScript
294 lines
14 KiB
TypeScript
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(<CustomSelect value="" onChange={onChange} options={OPTIONS} placeholder="Pick a fruit" />);
|
|
expect(screen.getByText('Pick a fruit')).toBeTruthy();
|
|
});
|
|
|
|
it('FE-COMP-SELECT-002: renders the selected option label', () => {
|
|
render(<CustomSelect value="banana" onChange={onChange} options={OPTIONS} placeholder="Pick" />);
|
|
expect(screen.getByText('Banana')).toBeTruthy();
|
|
});
|
|
|
|
it('FE-COMP-SELECT-003: clicking trigger opens the dropdown', async () => {
|
|
const user = userEvent.setup();
|
|
render(<CustomSelect value="" onChange={onChange} options={OPTIONS} />);
|
|
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(<CustomSelect value="" onChange={onChange} options={OPTIONS} />);
|
|
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(<CustomSelect value="" onChange={onChange} options={OPTIONS} />);
|
|
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(<CustomSelect value="" onChange={onChange} options={OPTIONS} />);
|
|
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(<CustomSelect value="" onChange={onChange} options={OPTIONS} searchable={true} />);
|
|
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(<CustomSelect value="" onChange={onChange} options={OPTIONS} />);
|
|
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(<CustomSelect value="" onChange={onChange} options={OPTIONS} />);
|
|
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(<CustomSelect value="" onChange={onChange} options={OPTIONS} />);
|
|
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(<CustomSelect value="" onChange={onChange} options={OPTIONS} menuFit="content" />);
|
|
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(
|
|
<div data-testid="panel">
|
|
<CustomSelect value="" onChange={onChange} options={OPTIONS} menuFit="content" />
|
|
</div>,
|
|
);
|
|
// 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(<CustomSelect value="12" onChange={onChange} options={USERS} placeholder="Pick a user" />);
|
|
|
|
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(<CustomSelect value={3} onChange={onChange} options={[{ value: '3', label: 'March' }, { value: '4', label: 'April' }]} placeholder="Month" />);
|
|
expect(screen.getByRole('button')).toHaveTextContent('March');
|
|
});
|
|
|
|
it('FE-COMP-SELECT-016: a caller holding nothing still sees the placeholder', () => {
|
|
render(<CustomSelect value={null} onChange={onChange} options={[{ value: '', label: 'Nobody' }, ...USERS]} placeholder="Pick a user" />);
|
|
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(<CustomSelect value="" onChange={onChange} options={USERS} />);
|
|
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(<CustomSelect value="" onChange={onChange} options={OPTIONS} disabled={true} placeholder="Pick" />);
|
|
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(<><label htmlFor="fruit">Fruit</label><CustomSelect id="fruit" value="apple" onChange={onChange} options={OPTIONS} /></>);
|
|
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(<CustomSelect id={id} ariaLabel="Choose fruit" value="apple" onChange={onChange} options={OPTIONS} />);
|
|
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(<>
|
|
<CustomSelect id="first-fruit" ariaLabel="First fruit" value="apple" onChange={onChange} options={OPTIONS} />
|
|
<CustomSelect id="second-fruit" ariaLabel="Second fruit" value="banana" onChange={onChange} options={OPTIONS} />
|
|
</>);
|
|
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(<CustomSelect ariaLabel="Choose fruit" value="" onChange={onChange} options={OPTIONS} />);
|
|
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(<CustomSelect ariaLabel="Choose fruit" value="" onChange={onChange} options={OPTIONS} searchable />);
|
|
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(<CustomSelect ariaLabel="Disabled fruit" value="apple" onChange={onChange} size="sm" disabled options={[{ value: 'apple', label: 'Apple', badge: '3', icon: <span data-testid="fruit-icon" /> }]} />);
|
|
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(<CustomSelect ariaLabel="Choose fruit" value="" onChange={onChange} options={OPTIONS} searchable />);
|
|
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();
|
|
});
|
|
});
|