-
Notifications
You must be signed in to change notification settings - Fork 0
Fix: Enhance keyboard navigation and focus for BasicSelect component #127
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| import { render, screen, fireEvent } from '@testing-library/react'; | ||
| import userEvent from '@testing-library/user-event'; | ||
| import { BasicSelect } from '../components/common/BasicSelect'; | ||
|
|
||
| describe('BasicSelect', () => { | ||
| const mockItems = [ | ||
| { label: 'Option 1', value: '1' }, | ||
| { label: 'Option 2', value: '2' }, | ||
| { label: 'Option 3', value: '3' }, | ||
| ]; | ||
|
|
||
| const mockOnChange = jest.fn(); | ||
|
|
||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| }); | ||
|
|
||
| it('renders with placeholder and opens on click', async () => { | ||
| render( | ||
| <BasicSelect | ||
| placeholder="Select an option" | ||
| items={mockItems} | ||
| onChange={mockOnChange} | ||
| /> | ||
| ); | ||
|
|
||
| expect(screen.getByText('Select an option')).toBeInTheDocument(); | ||
|
|
||
| await userEvent.click(screen.getByRole('combobox')); | ||
| expect(screen.getByText('Option 1')).toBeInTheDocument(); | ||
| expect(screen.getByText('Option 2')).toBeInTheDocument(); | ||
| expect(screen.getByText('Option 3')).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('allows selecting an option with click', async () => { | ||
| render( | ||
| <BasicSelect | ||
| placeholder="Select an option" | ||
| items={mockItems} | ||
| onChange={mockOnChange} | ||
| /> | ||
| ); | ||
|
|
||
| await userEvent.click(screen.getByRole('combobox')); | ||
| await userEvent.click(screen.getByText('Option 2')); | ||
|
|
||
| expect(mockOnChange).toHaveBeenCalledWith('2'); | ||
| expect(screen.queryByText('Option 1')).not.toBeInTheDocument(); // Menu should close | ||
| expect(screen.getByText('Option 2')).toBeInTheDocument(); // Selected value | ||
| }); | ||
|
|
||
| it('navigates options with arrow keys and selects with Enter', async () => { | ||
| render( | ||
| <BasicSelect | ||
| placeholder="Select an option" | ||
| items={mockItems} | ||
| onChange={mockOnChange} | ||
| /> | ||
| ); | ||
|
|
||
| const combobox = screen.getByRole('combobox'); | ||
| await userEvent.click(combobox); // Open the select | ||
|
|
||
| // Navigate down to Option 2 | ||
| fireEvent.keyDown(combobox, { key: 'ArrowDown' }); // Focus Option 1 | ||
| fireEvent.keyDown(combobox, { key: 'ArrowDown' }); // Focus Option 2 | ||
|
|
||
| // Select Option 2 with Enter | ||
| fireEvent.keyDown(combobox, { key: 'Enter' }); | ||
|
|
||
| expect(mockOnChange).toHaveBeenCalledWith('2'); | ||
| expect(screen.queryByText('Option 1')).not.toBeInTheDocument(); // Menu should close | ||
| expect(screen.getByText('Option 2')).toBeInTheDocument(); // Selected value | ||
| }); | ||
|
|
||
| it('closes the select with Escape key', async () => { | ||
| render( | ||
| <BasicSelect | ||
| placeholder="Select an option" | ||
| items={mockItems} | ||
| onChange={mockOnChange} | ||
| /> | ||
| ); | ||
|
|
||
| const combobox = screen.getByRole('combobox'); | ||
| await userEvent.click(combobox); // Open the select | ||
|
|
||
| expect(screen.getByText('Option 1')).toBeInTheDocument(); // Menu is open | ||
|
|
||
| fireEvent.keyDown(combobox, { key: 'Escape' }); | ||
|
|
||
| expect(screen.queryByText('Option 1')).not.toBeInTheDocument(); // Menu should close | ||
| }); | ||
|
|
||
| it('should clear selection when allowEmpty is true and clear button is clicked', async () => { | ||
| render( | ||
| <BasicSelect | ||
| placeholder="Select an option" | ||
| items={mockItems} | ||
| onChange={mockOnChange} | ||
| value="1" | ||
| allowEmpty={true} | ||
| /> | ||
| ); | ||
|
|
||
| expect(screen.getByText('Option 1')).toBeInTheDocument(); | ||
|
|
||
| const clearButton = screen.getByRole('button', { name: /clear/i }); | ||
| await userEvent.click(clearButton); | ||
|
|
||
| expect(mockOnChange).toHaveBeenCalledWith(null); | ||
| expect(screen.getByText('Select an option')).toBeInTheDocument(); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,52 +42,53 @@ export function BasicSelect({ | |
| )} | ||
| {helperText && <FormDescription>{helperText}</FormDescription>} | ||
| <div className='relative'> | ||
| <Select | ||
| disabled={disabled} | ||
| onValueChange={onChange} | ||
| value={value?.toString() ?? ''} | ||
| onOpenChange={(isOpen) => { | ||
| if (!isOpen && !hasValue) { | ||
| onChange(null); | ||
| } | ||
| }} | ||
| > | ||
| <SelectTrigger | ||
| className={cn( | ||
| 'bg-transparent hover:bg-transparent focus:ring-0 border-neutral-700/60 w-full dark:bg-neutral-900 dark:text-white dark:border-neutral-700', | ||
| allowEmpty && hasValue && 'pr-12', | ||
| className | ||
| )} | ||
| > | ||
| <SelectValue placeholder={placeholder} /> | ||
| </SelectTrigger> | ||
| <SelectContent className='dark:bg-neutral-900 dark:border-neutral-700'> | ||
| <SelectGroup> | ||
| {items.map(({ value, label, disabled }) => ( | ||
| <SelectItem | ||
| key={value} | ||
| value={value.toString()} | ||
| disabled={disabled} | ||
| className='dark:text-white dark:focus:bg-neutral-800 dark:focus:text-white' | ||
| > | ||
| {label} | ||
| </SelectItem> | ||
| ))} | ||
| </SelectGroup> | ||
| </SelectContent> | ||
| </Select> | ||
| {allowEmpty && hasValue && ( | ||
| <button | ||
| type='button' | ||
| onClick={(e) => { | ||
| e.stopPropagation(); | ||
| onChange(null); | ||
| <div className="relative flex items-center"> | ||
| <Select | ||
| disabled={disabled} | ||
| onValueChange={onChange} | ||
| value={value?.toString() ?? ''} | ||
| onOpenChange={(isOpen) => { | ||
| if (!isOpen && !hasValue) { | ||
| onChange(null); | ||
| } | ||
| }} | ||
|
Comment on lines
+50
to
54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Potential “select then immediately clear” bug via When an option is selected, the dropdown typically closes; if 🤖 Prompt for AI Agents |
||
| className='absolute right-2 top-1/2 -translate-y-1/2 hover:bg-neutral-100 dark:hover:bg-neutral-800 p-1.5 rounded-sm' | ||
| > | ||
| <X className='h-3.5 w-3.5 text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-50' /> | ||
| </button> | ||
| )} | ||
| <SelectTrigger | ||
| className={cn( | ||
| 'bg-transparent hover:bg-transparent focus:ring-0 border-neutral-700/60 w-full dark:bg-neutral-900 dark:text-white dark:border-neutral-700', | ||
| allowEmpty && hasValue && 'pr-12', | ||
| className | ||
| )} | ||
| > | ||
| <SelectValue placeholder={placeholder} /> | ||
| </SelectTrigger> | ||
| <SelectContent className='dark:bg-neutral-900 dark:border-neutral-700'> | ||
| <SelectGroup> | ||
| {items.map(({ value, label, disabled }) => ( | ||
| <SelectItem | ||
| key={value} | ||
| value={value.toString()} | ||
| disabled={disabled} | ||
| className='dark:text-white dark:focus:bg-neutral-800 dark:focus:text-white' | ||
| > | ||
| {label} | ||
| </SelectItem> | ||
| ))} | ||
| </SelectGroup> | ||
| </SelectContent> | ||
| </Select> | ||
| {allowEmpty && hasValue && ( | ||
| <button | ||
| type='button' | ||
| onClick={() => { | ||
| onChange(null); | ||
| }} | ||
| className='absolute right-2 top-1/2 -translate-y-1/2 hover:bg-neutral-100 dark:hover:bg-neutral-800 p-1.5 rounded-sm' | ||
| > | ||
| <X className='h-3.5 w-3.5 text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-50' /> | ||
| </button> | ||
| )} | ||
|
Comment on lines
+80
to
+90
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Clear button needs an accessible name (and should match the test) Add {allowEmpty && hasValue && (
<button
type='button'
+ aria-label='Clear selection'
onClick={() => {
onChange(null);
}}
className='absolute right-2 top-1/2 -translate-y-1/2 hover:bg-neutral-100 dark:hover:bg-neutral-800 p-1.5 rounded-sm'
>
- <X className='h-3.5 w-3.5 text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-50' />
+ <X
+ aria-hidden='true'
+ className='h-3.5 w-3.5 text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-50'
+ />
</button>
)}🤖 Prompt for AI Agents |
||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Clear-button test will fail unless the button has an accessible name
getByRole('button', { name: /clear/i })won’t match the current clear button (icon-only, noaria-label). Please add anaria-label(or visible text) insrc/components/common/BasicSelect.tsx, and then assert against that label.🤖 Prompt for AI Agents