Skip to content

Commit 6a88fe6

Browse files
feat: add keyboard navigation to Select component
- Add activeIndex state tracking with default value 0 - Implement ArrowUp/ArrowDown key handlers with bounds checking - Reset activeIndex when filtered items change - Add aria-activedescendant on search input for accessibility - Generate unique IDs for each option element - Add visual highlighting for active items (bg-gray-50) - Implement scroll-into-view for active items - Update Enter key to select active item instead of enterCandidate - Add comprehensive keyboard navigation test story - Create changeset for patch version bump - Maintain all existing visuals, API, and functionality Co-authored-by: Jake Ruesink <jaruesink@gmail.com>
1 parent 6962b05 commit 6a88fe6

File tree

18 files changed

+170
-101
lines changed

18 files changed

+170
-101
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@lambdacurry/forms": patch
3+
---
4+
5+
Select: add keyboard navigation with active item and Enter selection; maintain visuals and API.

apps/docs/src/remix-hook-form/phone-input.stories.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,7 @@ const ControlledPhoneInputExample = () => {
3737
<RemixFormProvider {...methods}>
3838
<fetcher.Form onSubmit={methods.handleSubmit}>
3939
<div className="grid gap-8">
40-
<PhoneInput
41-
name="usaPhone"
42-
label="Phone Number"
43-
description="Enter a US phone number"
44-
/>
40+
<PhoneInput name="usaPhone" label="Phone Number" description="Enter a US phone number" />
4541
<PhoneInput
4642
name="internationalPhone"
4743
label="International Phone Number"

apps/docs/src/remix-hook-form/select.stories.tsx

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,3 +348,96 @@ export const FormSubmission: Story = {
348348
});
349349
},
350350
};
351+
352+
export const KeyboardNavigation: Story = {
353+
parameters: {
354+
docs: {
355+
description: {
356+
story: 'Test keyboard navigation with arrow keys and Enter selection.',
357+
},
358+
},
359+
},
360+
decorators: [selectRouterDecorator],
361+
play: async ({ canvasElement, step }) => {
362+
const canvas = within(canvasElement);
363+
364+
await step('Test keyboard navigation on Custom Region select', async () => {
365+
// Open the Custom Region select
366+
const regionSelect = canvas.getByLabelText('Custom Region');
367+
await userEvent.click(regionSelect);
368+
369+
// Verify the dropdown is open and input is focused
370+
const listbox = await within(document.body).findByRole('listbox');
371+
expect(listbox).toBeInTheDocument();
372+
373+
const searchInput = within(listbox).getByPlaceholderText('Search...');
374+
expect(searchInput).toHaveFocus();
375+
376+
// Verify first item is active by default (should have aria-activedescendant)
377+
const firstOptionId = searchInput.getAttribute('aria-activedescendant');
378+
expect(firstOptionId).toBeTruthy();
379+
380+
// Verify the first option exists and has the correct ID
381+
const firstOption = within(listbox).getByRole('option', { name: 'Alabama' });
382+
expect(firstOption).toHaveAttribute('id', firstOptionId);
383+
expect(firstOption).toHaveAttribute('data-active', 'true');
384+
});
385+
386+
await step('Navigate with arrow keys', async () => {
387+
const listbox = within(document.body).getByRole('listbox');
388+
const searchInput = within(listbox).getByPlaceholderText('Search...');
389+
390+
// Press ArrowDown twice to move to the third item
391+
await userEvent.keyboard('{ArrowDown}');
392+
await userEvent.keyboard('{ArrowDown}');
393+
394+
// Verify the active item has changed
395+
const activeOptionId = searchInput.getAttribute('aria-activedescendant');
396+
const activeOption = document.getElementById(activeOptionId!);
397+
expect(activeOption).toHaveAttribute('data-active', 'true');
398+
399+
// Should be the third option (index 2)
400+
expect(activeOption).toHaveAttribute('data-index', '2');
401+
});
402+
403+
await step('Select with Enter key', async () => {
404+
const listbox = within(document.body).getByRole('listbox');
405+
const searchInput = within(listbox).getByPlaceholderText('Search...');
406+
407+
// Press Enter to select the active item
408+
await userEvent.keyboard('{Enter}');
409+
410+
// Verify the dropdown closed and the trigger shows the selected value
411+
await expect(() => within(document.body).getByRole('listbox')).rejects.toThrow();
412+
413+
const regionSelect = canvas.getByLabelText('Custom Region');
414+
// The third item should be "Arizona" (AL, AK, AZ...)
415+
expect(regionSelect).toHaveTextContent('Arizona');
416+
});
417+
418+
await step('Test filtering and active item reset', async () => {
419+
// Open the dropdown again
420+
const regionSelect = canvas.getByLabelText('Custom Region');
421+
await userEvent.click(regionSelect);
422+
423+
const listbox = await within(document.body).findByRole('listbox');
424+
const searchInput = within(listbox).getByPlaceholderText('Search...');
425+
426+
// Type to filter
427+
await userEvent.type(searchInput, 'cal');
428+
429+
// Verify the active item reset to the first filtered item
430+
const activeOptionId = searchInput.getAttribute('aria-activedescendant');
431+
const activeOption = document.getElementById(activeOptionId!);
432+
expect(activeOption).toHaveAttribute('data-index', '0');
433+
expect(activeOption).toHaveTextContent('California');
434+
435+
// Press Enter to select the filtered item
436+
await userEvent.keyboard('{Enter}');
437+
438+
// Verify selection
439+
await expect(() => within(document.body).getByRole('listbox')).rejects.toThrow();
440+
expect(regionSelect).toHaveTextContent('California');
441+
});
442+
},
443+
};
Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,79 @@
1+
import type { StoryContext } from '@storybook/react';
12
import { expect } from '@storybook/test';
23
import { userEvent, within } from '@storybook/testing-library';
3-
import { StoryContext } from '@storybook/react';
44

55
// Test selecting a US state
66
export const testUSStateSelection = async ({ canvasElement }: StoryContext) => {
77
const canvas = within(canvasElement);
8-
8+
99
// Find and click the US state dropdown
1010
const stateDropdown = canvas.getByLabelText('US State');
1111
await userEvent.click(stateDropdown);
12-
12+
1313
// Select a state (e.g., California)
1414
const californiaOption = await canvas.findByText('California');
1515
await userEvent.click(californiaOption);
16-
16+
1717
// Verify the selection
1818
expect(stateDropdown).toHaveTextContent('California');
1919
};
2020

2121
// Test selecting a Canadian province
2222
export const testCanadaProvinceSelection = async ({ canvasElement }: StoryContext) => {
2323
const canvas = within(canvasElement);
24-
24+
2525
// Find and click the Canada province dropdown
2626
const provinceDropdown = canvas.getByLabelText('Canadian Province');
2727
await userEvent.click(provinceDropdown);
28-
28+
2929
// Select a province (e.g., Ontario)
3030
const ontarioOption = await canvas.findByText('Ontario');
3131
await userEvent.click(ontarioOption);
32-
32+
3333
// Verify the selection
3434
expect(provinceDropdown).toHaveTextContent('Ontario');
3535
};
3636

3737
// Test form submission
3838
export const testFormSubmission = async ({ canvasElement }: StoryContext) => {
3939
const canvas = within(canvasElement);
40-
40+
4141
// Select a state
4242
const stateDropdown = canvas.getByLabelText('US State');
4343
await userEvent.click(stateDropdown);
4444
const californiaOption = await canvas.findByText('California');
4545
await userEvent.click(californiaOption);
46-
46+
4747
// Select a province
4848
const provinceDropdown = canvas.getByLabelText('Canadian Province');
4949
await userEvent.click(provinceDropdown);
5050
const ontarioOption = await canvas.findByText('Ontario');
5151
await userEvent.click(ontarioOption);
52-
52+
5353
// Select a custom region
5454
const regionDropdown = canvas.getByLabelText('Custom Region');
5555
await userEvent.click(regionDropdown);
5656
const customOption = await canvas.findByText('New York');
5757
await userEvent.click(customOption);
58-
58+
5959
// Submit the form
6060
const submitButton = canvas.getByRole('button', { name: 'Submit' });
6161
await userEvent.click(submitButton);
62-
62+
6363
// Verify the submission (mock response would be shown)
6464
await expect(canvas.findByText('Selected regions:')).resolves.toBeInTheDocument();
6565
};
6666

6767
// Test validation errors
6868
export const testValidationErrors = async ({ canvasElement }: StoryContext) => {
6969
const canvas = within(canvasElement);
70-
70+
7171
// Submit the form without selecting anything
7272
const submitButton = canvas.getByRole('button', { name: 'Submit' });
7373
await userEvent.click(submitButton);
74-
74+
7575
// Verify error messages
7676
await expect(canvas.findByText('Please select a state')).resolves.toBeInTheDocument();
7777
await expect(canvas.findByText('Please select a province')).resolves.toBeInTheDocument();
7878
await expect(canvas.findByText('Please select a region')).resolves.toBeInTheDocument();
7979
};
80-

package.json

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,7 @@
22
"name": "forms",
33
"version": "0.2.0",
44
"private": true,
5-
"workspaces": [
6-
"apps/*",
7-
"packages/*"
8-
],
5+
"workspaces": ["apps/*", "packages/*"],
96
"scripts": {
107
"start": "yarn dev",
118
"dev": "turbo run dev",

packages/components/package.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,7 @@
2626
"import": "./dist/ui/*.js"
2727
}
2828
},
29-
"files": [
30-
"dist"
31-
],
29+
"files": ["dist"],
3230
"scripts": {
3331
"prepublishOnly": "yarn run build",
3432
"build": "vite build",
Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,8 @@
1-
import * as React from 'react';
2-
import { Select, type SelectProps } from './select';
31
import { CANADA_PROVINCES } from '../ui/data/canada-provinces';
2+
import { Select, type SelectProps } from './select';
43

54
export type CanadaProvinceSelectProps = Omit<SelectProps, 'options'>;
65

76
export function CanadaProvinceSelect(props: CanadaProvinceSelectProps) {
8-
return (
9-
<Select
10-
{...props}
11-
options={CANADA_PROVINCES}
12-
placeholder="Select a province"
13-
/>
14-
);
7+
return <Select {...props} options={CANADA_PROVINCES} placeholder="Select a province" />;
158
}
16-

packages/components/src/remix-hook-form/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,3 @@ export * from './use-data-table-url-state';
1717
export * from './select';
1818
export * from './us-state-select';
1919
export * from './canada-province-select';
20-

packages/components/src/remix-hook-form/phone-input.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
import * as React from 'react';
2-
import { PhoneInputField as BasePhoneInputField, type PhoneInputFieldProps as BasePhoneInputFieldProps } from '../ui/phone-input-field';
1+
import type * as React from 'react';
2+
import {
3+
PhoneInputField as BasePhoneInputField,
4+
type PhoneInputFieldProps as BasePhoneInputFieldProps,
5+
} from '../ui/phone-input-field';
36
import { FormControl, FormDescription, FormLabel, FormMessage } from './form';
47

58
import { useRemixFormContext } from 'remix-hook-form';

packages/components/src/remix-hook-form/select.tsx

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import * as React from 'react';
1+
import type * as React from 'react';
22
import { useRemixFormContext } from 'remix-hook-form';
3-
import { FormControl, FormDescription, FormLabel, FormMessage } from './form';
43
import { FormField, FormItem } from '../ui/form';
5-
import { Select as UISelect, type SelectProps as UISelectProps, type SelectUIComponents } from '../ui/select';
4+
import { type SelectUIComponents, Select as UISelect, type SelectProps as UISelectProps } from '../ui/select';
5+
import { FormControl, FormDescription, FormLabel, FormMessage } from './form';
66

77
export interface SelectProps extends Omit<UISelectProps, 'value' | 'onValueChange'> {
88
name: string;
@@ -19,14 +19,7 @@ export interface SelectProps extends Omit<UISelectProps, 'value' | 'onValueChang
1919
>;
2020
}
2121

22-
export function Select({
23-
name,
24-
label,
25-
description,
26-
className,
27-
components,
28-
...props
29-
}: SelectProps) {
22+
export function Select({ name, label, description, className, components, ...props }: SelectProps) {
3023
const { control } = useRemixFormContext();
3124

3225
return (
@@ -50,9 +43,7 @@ export function Select({
5043
}}
5144
/>
5245
</FormControl>
53-
{description && (
54-
<FormDescription Component={components?.FormDescription}>{description}</FormDescription>
55-
)}
46+
{description && <FormDescription Component={components?.FormDescription}>{description}</FormDescription>}
5647
<FormMessage Component={components?.FormMessage} />
5748
</FormItem>
5849
)}

0 commit comments

Comments
 (0)