Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/keyboard-navigation-select.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@lambdacurry/forms": patch
---

Select: add keyboard navigation with active item and Enter selection; maintain visuals and API.
2 changes: 1 addition & 1 deletion apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"@storybook/react-vite": "^9.0.6",
"react": "^19.0.0",
"react-hook-form": "^7.51.0",
"react-router": "^7.6.1",
"react-router": "^7.6.3",
"remix-hook-form": "^7.1.0",
"storybook": "^9.0.6"
},
Expand Down
11 changes: 7 additions & 4 deletions apps/docs/src/lib/storybook/react-router-stub.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import {
type LinksFunction,
type LoaderFunction,
type MetaFunction,
createRoutesStub,
createMemoryRouter,
RouterProvider,
} from 'react-router';

export interface StubRouteObject {
Expand Down Expand Up @@ -47,10 +48,12 @@ export const withReactRouterStubDecorator = (options: RemixStubOptions): Decorat
// Combine them for the initial entry
const actualInitialPath = `${basePath}${currentWindowSearch}`;

// Use React Router's official createRoutesStub
const Stub = createRoutesStub(mappedRoutes);
// Use React Router's createMemoryRouter instead of createRoutesStub
const router = createMemoryRouter(mappedRoutes, {
initialEntries: [actualInitialPath],
});

return <Stub initialEntries={[actualInitialPath]} />;
return <RouterProvider router={router} />;
};
};

Expand Down
6 changes: 1 addition & 5 deletions apps/docs/src/remix-hook-form/phone-input.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,7 @@ const ControlledPhoneInputExample = () => {
<RemixFormProvider {...methods}>
<fetcher.Form onSubmit={methods.handleSubmit}>
<div className="grid gap-8">
<PhoneInput
name="usaPhone"
label="Phone Number"
description="Enter a US phone number"
/>
<PhoneInput name="usaPhone" label="Phone Number" description="Enter a US phone number" />
<PhoneInput
name="internationalPhone"
label="International Phone Number"
Expand Down
237 changes: 231 additions & 6 deletions apps/docs/src/remix-hook-form/select.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { Button } from '@lambdacurry/forms/ui/button';
import { CANADA_PROVINCES } from '@lambdacurry/forms/ui/data/canada-provinces';
import { US_STATES } from '@lambdacurry/forms/ui/data/us-states';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, userEvent, within } from '@storybook/test';
import { type ActionFunctionArgs, useFetcher } from 'react-router';
import { expect, fireEvent, userEvent, waitFor, within } from '@storybook/test';
import { type ActionFunctionArgs, useFetcher, createMemoryRouter, RouterProvider } from 'react-router';
import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form';
import { z } from 'zod';
import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub';
Expand Down Expand Up @@ -68,6 +68,21 @@ const RegionSelectExample = () => {
);
};

// Create a wrapper that provides Router context directly
const RouterWrapper = ({ children }: { children: React.ReactNode }) => {
const router = createMemoryRouter([
{
path: '/',
Component: () => <>{children}</>,
action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request),
},
], {
initialEntries: ['/'],
});

return <RouterProvider router={router} />;
};

const handleFormSubmission = async (request: Request) => {
const { data, errors } = await getValidatedFormData<FormData>(request, zodResolver(formSchema));

Expand Down Expand Up @@ -170,7 +185,11 @@ const RegionSelectExample = () => {
},
},
},
decorators: [selectRouterDecorator],
render: () => (
<RouterWrapper>
<RegionSelectExample />
</RouterWrapper>
),
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);

Expand Down Expand Up @@ -249,7 +268,11 @@ export const USStateSelection: Story = {
},
},
},
decorators: [selectRouterDecorator],
render: () => (
<RouterWrapper>
<RegionSelectExample />
</RouterWrapper>
),
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);

Expand Down Expand Up @@ -277,7 +300,11 @@ export const CanadaProvinceSelection: Story = {
},
},
},
decorators: [selectRouterDecorator],
render: () => (
<RouterWrapper>
<RegionSelectExample />
</RouterWrapper>
),
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);

Expand Down Expand Up @@ -305,7 +332,11 @@ export const FormSubmission: Story = {
},
},
},
decorators: [selectRouterDecorator],
render: () => (
<RouterWrapper>
<RegionSelectExample />
</RouterWrapper>
),
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);

Expand Down Expand Up @@ -348,3 +379,197 @@ export const FormSubmission: Story = {
});
},
};

export const KeyboardNavigation: Story = {
parameters: {
docs: {
description: {
story: 'Test keyboard navigation with arrow keys and Enter selection.',
},
},
},
render: () => (
<RouterWrapper>
<RegionSelectExample />
</RouterWrapper>
),
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);

await step('Test keyboard navigation on Custom Region select', async () => {
// Open the Custom Region select
const regionSelect = canvas.getByLabelText('Custom Region');
await userEvent.click(regionSelect);

// Wait for the ShadCN popover to render in the Portal (document.body)
// This is the key fix: ShadCN components render via Radix UI Portal to document.body
const listbox = await waitFor(
() => within(document.body).getByRole('listbox'),
{ timeout: 3000 }
);
expect(listbox).toBeInTheDocument();

// Wait for the search input to be focused and ready
const searchInput = await waitFor(
() => within(listbox).getByPlaceholderText('Search...'),
{ timeout: 1000 }
);

// Wait for focus to be properly set (ShadCN uses queueMicrotask for focus)
await waitFor(() => {
expect(searchInput).toHaveFocus();
}, { timeout: 1000 });

// Wait for the component to initialize and set aria-activedescendant
await waitFor(() => {
const activeOptionId = searchInput.getAttribute('aria-activedescendant');
expect(activeOptionId).toBeTruthy();
return activeOptionId;
}, { timeout: 1000 });

// Wait for the component's 100ms initialization delay to complete
// This is crucial for keyboard navigation to work properly
await new Promise(resolve => setTimeout(resolve, 150));

// Get the current active option ID after initialization
const firstOptionId = searchInput.getAttribute('aria-activedescendant');

// Verify the first option exists and has the correct ID
const firstOption = within(listbox).getByRole('option', { name: 'Alabama' });
expect(firstOption).toHaveAttribute('id', firstOptionId);

// Verify the active state is properly set
await waitFor(() => {
const currentActiveOption = document.getElementById(firstOptionId!);
expect(currentActiveOption).toHaveAttribute('data-active', 'true');
}, { timeout: 1000 });
});

await step('Navigate with arrow keys', async () => {
// Get the listbox that should still be open from the previous step
const listbox = within(document.body).getByRole('listbox');
const searchInput = within(listbox).getByPlaceholderText('Search...');

// Verify initial state
const initialActiveOptionId = searchInput.getAttribute('aria-activedescendant');
const initialActiveOption = document.getElementById(initialActiveOptionId!);
expect(initialActiveOption).toHaveAttribute('data-index', '0');

// Press ArrowDown once to move to the second item
// Focus the search input first to ensure keyboard events are received
searchInput.focus();

fireEvent.keyDown(searchInput, {
key: 'ArrowDown',
code: 'ArrowDown',
keyCode: 40,
which: 40,
bubbles: true,
cancelable: true
});

// Wait for first arrow down to take effect
await waitFor(() => {
const activeOptionId = searchInput.getAttribute('aria-activedescendant');
const activeOption = document.getElementById(activeOptionId!);
expect(activeOption).toHaveAttribute('data-index', '1');
}, { timeout: 2000 });

fireEvent.keyDown(searchInput, {
key: 'ArrowDown',
code: 'ArrowDown',
keyCode: 40,
which: 40,
bubbles: true,
cancelable: true
});

// Wait for the aria-activedescendant to update after second keyboard navigation
await waitFor(() => {
const activeOptionId = searchInput.getAttribute('aria-activedescendant');
const activeOption = document.getElementById(activeOptionId!);
console.log('After second arrow down - expected index 2, actual:', activeOption?.getAttribute('data-index'));
expect(activeOption).toHaveAttribute('data-index', '2');
return activeOption;
}, { timeout: 2000 });

// Verify the active state is properly set on the third option
const activeOptionId = searchInput.getAttribute('aria-activedescendant');
const activeOption = document.getElementById(activeOptionId!);
expect(activeOption).toHaveAttribute('data-active', 'true');
});

await step('Select with Enter key', async () => {
const listbox = within(document.body).getByRole('listbox');
const searchInput = within(listbox).getByPlaceholderText('Search...');

// Press Enter to select the active item
fireEvent.keyDown(searchInput, {
key: 'Enter',
code: 'Enter',
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true
});

// Wait for the ShadCN popover to close (Portal cleanup)
await waitFor(() => {
expect(() => within(document.body).getByRole('listbox')).toThrow();
}, { timeout: 2000 });

// Verify the trigger shows the selected value
const regionSelect = canvas.getByLabelText('Custom Region');
// The third item should be "Arizona" (AL, AK, AZ...)
await waitFor(() => {
expect(regionSelect).toHaveTextContent('Arizona');
}, { timeout: 1000 });
});

await step('Test filtering and active item reset', async () => {
// Open the dropdown again
const regionSelect = canvas.getByLabelText('Custom Region');
await userEvent.click(regionSelect);

// Wait for the ShadCN popover to open again
const listbox = await waitFor(
() => within(document.body).getByRole('listbox'),
{ timeout: 3000 }
);
const searchInput = await waitFor(
() => within(listbox).getByPlaceholderText('Search...'),
{ timeout: 1000 }
);

// Type to filter
await userEvent.type(searchInput, 'cal');

// Wait for filtering to complete and active item to reset
await waitFor(() => {
const activeOptionId = searchInput.getAttribute('aria-activedescendant');
const activeOption = document.getElementById(activeOptionId!);
expect(activeOption).toHaveAttribute('data-index', '0');
expect(activeOption).toHaveTextContent('California');
}, { timeout: 1000 });

// Press Enter to select the filtered item
fireEvent.keyDown(searchInput, {
key: 'Enter',
code: 'Enter',
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true
});

// Wait for the popover to close and selection to update
await waitFor(() => {
expect(() => within(document.body).getByRole('listbox')).toThrow();
}, { timeout: 2000 });

await waitFor(() => {
expect(regionSelect).toHaveTextContent('California');
}, { timeout: 1000 });
});
},
};
Loading
Loading