From 6a88fe62e70548bb9ba5cb5a0f5b8c4b51a76631 Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Wed, 17 Sep 2025 04:42:10 +0000 Subject: [PATCH 1/6] 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 --- .changeset/keyboard-navigation-select.md | 5 + .../remix-hook-form/phone-input.stories.tsx | 6 +- .../src/remix-hook-form/select.stories.tsx | 93 +++++++++++++++++++ apps/docs/src/remix-hook-form/select.test.tsx | 29 +++--- package.json | 5 +- packages/components/package.json | 4 +- .../canada-province-select.tsx | 12 +-- .../components/src/remix-hook-form/index.ts | 1 - .../src/remix-hook-form/phone-input.tsx | 7 +- .../components/src/remix-hook-form/select.tsx | 19 +--- .../src/remix-hook-form/us-state-select.tsx | 12 +-- .../src/ui/canada-province-select.tsx | 12 +-- .../src/ui/data/canada-provinces.ts | 2 +- packages/components/src/ui/data/us-states.ts | 2 +- packages/components/src/ui/index.ts | 1 - packages/components/src/ui/phone-input.tsx | 4 +- packages/components/src/ui/select.tsx | 45 ++++++--- .../components/src/ui/us-state-select.tsx | 12 +-- 18 files changed, 170 insertions(+), 101 deletions(-) create mode 100644 .changeset/keyboard-navigation-select.md diff --git a/.changeset/keyboard-navigation-select.md b/.changeset/keyboard-navigation-select.md new file mode 100644 index 00000000..52eefc3a --- /dev/null +++ b/.changeset/keyboard-navigation-select.md @@ -0,0 +1,5 @@ +--- +"@lambdacurry/forms": patch +--- + +Select: add keyboard navigation with active item and Enter selection; maintain visuals and API. diff --git a/apps/docs/src/remix-hook-form/phone-input.stories.tsx b/apps/docs/src/remix-hook-form/phone-input.stories.tsx index 08e77343..82b74fea 100644 --- a/apps/docs/src/remix-hook-form/phone-input.stories.tsx +++ b/apps/docs/src/remix-hook-form/phone-input.stories.tsx @@ -37,11 +37,7 @@ const ControlledPhoneInputExample = () => {
- + { + 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); + + // Verify the dropdown is open and input is focused + const listbox = await within(document.body).findByRole('listbox'); + expect(listbox).toBeInTheDocument(); + + const searchInput = within(listbox).getByPlaceholderText('Search...'); + expect(searchInput).toHaveFocus(); + + // Verify first item is active by default (should have aria-activedescendant) + const firstOptionId = searchInput.getAttribute('aria-activedescendant'); + expect(firstOptionId).toBeTruthy(); + + // Verify the first option exists and has the correct ID + const firstOption = within(listbox).getByRole('option', { name: 'Alabama' }); + expect(firstOption).toHaveAttribute('id', firstOptionId); + expect(firstOption).toHaveAttribute('data-active', 'true'); + }); + + await step('Navigate with arrow keys', async () => { + const listbox = within(document.body).getByRole('listbox'); + const searchInput = within(listbox).getByPlaceholderText('Search...'); + + // Press ArrowDown twice to move to the third item + await userEvent.keyboard('{ArrowDown}'); + await userEvent.keyboard('{ArrowDown}'); + + // Verify the active item has changed + const activeOptionId = searchInput.getAttribute('aria-activedescendant'); + const activeOption = document.getElementById(activeOptionId!); + expect(activeOption).toHaveAttribute('data-active', 'true'); + + // Should be the third option (index 2) + expect(activeOption).toHaveAttribute('data-index', '2'); + }); + + 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 + await userEvent.keyboard('{Enter}'); + + // Verify the dropdown closed and the trigger shows the selected value + await expect(() => within(document.body).getByRole('listbox')).rejects.toThrow(); + + const regionSelect = canvas.getByLabelText('Custom Region'); + // The third item should be "Arizona" (AL, AK, AZ...) + expect(regionSelect).toHaveTextContent('Arizona'); + }); + + await step('Test filtering and active item reset', async () => { + // Open the dropdown again + const regionSelect = canvas.getByLabelText('Custom Region'); + await userEvent.click(regionSelect); + + const listbox = await within(document.body).findByRole('listbox'); + const searchInput = within(listbox).getByPlaceholderText('Search...'); + + // Type to filter + await userEvent.type(searchInput, 'cal'); + + // Verify the active item reset to the first filtered item + const activeOptionId = searchInput.getAttribute('aria-activedescendant'); + const activeOption = document.getElementById(activeOptionId!); + expect(activeOption).toHaveAttribute('data-index', '0'); + expect(activeOption).toHaveTextContent('California'); + + // Press Enter to select the filtered item + await userEvent.keyboard('{Enter}'); + + // Verify selection + await expect(() => within(document.body).getByRole('listbox')).rejects.toThrow(); + expect(regionSelect).toHaveTextContent('California'); + }); + }, +}; diff --git a/apps/docs/src/remix-hook-form/select.test.tsx b/apps/docs/src/remix-hook-form/select.test.tsx index 7e502e7e..271aa2f9 100644 --- a/apps/docs/src/remix-hook-form/select.test.tsx +++ b/apps/docs/src/remix-hook-form/select.test.tsx @@ -1,19 +1,19 @@ +import type { StoryContext } from '@storybook/react'; import { expect } from '@storybook/test'; import { userEvent, within } from '@storybook/testing-library'; -import { StoryContext } from '@storybook/react'; // Test selecting a US state export const testUSStateSelection = async ({ canvasElement }: StoryContext) => { const canvas = within(canvasElement); - + // Find and click the US state dropdown const stateDropdown = canvas.getByLabelText('US State'); await userEvent.click(stateDropdown); - + // Select a state (e.g., California) const californiaOption = await canvas.findByText('California'); await userEvent.click(californiaOption); - + // Verify the selection expect(stateDropdown).toHaveTextContent('California'); }; @@ -21,15 +21,15 @@ export const testUSStateSelection = async ({ canvasElement }: StoryContext) => { // Test selecting a Canadian province export const testCanadaProvinceSelection = async ({ canvasElement }: StoryContext) => { const canvas = within(canvasElement); - + // Find and click the Canada province dropdown const provinceDropdown = canvas.getByLabelText('Canadian Province'); await userEvent.click(provinceDropdown); - + // Select a province (e.g., Ontario) const ontarioOption = await canvas.findByText('Ontario'); await userEvent.click(ontarioOption); - + // Verify the selection expect(provinceDropdown).toHaveTextContent('Ontario'); }; @@ -37,29 +37,29 @@ export const testCanadaProvinceSelection = async ({ canvasElement }: StoryContex // Test form submission export const testFormSubmission = async ({ canvasElement }: StoryContext) => { const canvas = within(canvasElement); - + // Select a state const stateDropdown = canvas.getByLabelText('US State'); await userEvent.click(stateDropdown); const californiaOption = await canvas.findByText('California'); await userEvent.click(californiaOption); - + // Select a province const provinceDropdown = canvas.getByLabelText('Canadian Province'); await userEvent.click(provinceDropdown); const ontarioOption = await canvas.findByText('Ontario'); await userEvent.click(ontarioOption); - + // Select a custom region const regionDropdown = canvas.getByLabelText('Custom Region'); await userEvent.click(regionDropdown); const customOption = await canvas.findByText('New York'); await userEvent.click(customOption); - + // Submit the form const submitButton = canvas.getByRole('button', { name: 'Submit' }); await userEvent.click(submitButton); - + // Verify the submission (mock response would be shown) await expect(canvas.findByText('Selected regions:')).resolves.toBeInTheDocument(); }; @@ -67,14 +67,13 @@ export const testFormSubmission = async ({ canvasElement }: StoryContext) => { // Test validation errors export const testValidationErrors = async ({ canvasElement }: StoryContext) => { const canvas = within(canvasElement); - + // Submit the form without selecting anything const submitButton = canvas.getByRole('button', { name: 'Submit' }); await userEvent.click(submitButton); - + // Verify error messages await expect(canvas.findByText('Please select a state')).resolves.toBeInTheDocument(); await expect(canvas.findByText('Please select a province')).resolves.toBeInTheDocument(); await expect(canvas.findByText('Please select a region')).resolves.toBeInTheDocument(); }; - diff --git a/package.json b/package.json index 609afa67..80343420 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,7 @@ "name": "forms", "version": "0.2.0", "private": true, - "workspaces": [ - "apps/*", - "packages/*" - ], + "workspaces": ["apps/*", "packages/*"], "scripts": { "start": "yarn dev", "dev": "turbo run dev", diff --git a/packages/components/package.json b/packages/components/package.json index d8e85420..6fab5000 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -26,9 +26,7 @@ "import": "./dist/ui/*.js" } }, - "files": [ - "dist" - ], + "files": ["dist"], "scripts": { "prepublishOnly": "yarn run build", "build": "vite build", diff --git a/packages/components/src/remix-hook-form/canada-province-select.tsx b/packages/components/src/remix-hook-form/canada-province-select.tsx index fb697b29..072935bd 100644 --- a/packages/components/src/remix-hook-form/canada-province-select.tsx +++ b/packages/components/src/remix-hook-form/canada-province-select.tsx @@ -1,16 +1,8 @@ -import * as React from 'react'; -import { Select, type SelectProps } from './select'; import { CANADA_PROVINCES } from '../ui/data/canada-provinces'; +import { Select, type SelectProps } from './select'; export type CanadaProvinceSelectProps = Omit; export function CanadaProvinceSelect(props: CanadaProvinceSelectProps) { - return ( - ; } - diff --git a/packages/components/src/remix-hook-form/index.ts b/packages/components/src/remix-hook-form/index.ts index fe23071f..21d76b99 100644 --- a/packages/components/src/remix-hook-form/index.ts +++ b/packages/components/src/remix-hook-form/index.ts @@ -17,4 +17,3 @@ export * from './use-data-table-url-state'; export * from './select'; export * from './us-state-select'; export * from './canada-province-select'; - diff --git a/packages/components/src/remix-hook-form/phone-input.tsx b/packages/components/src/remix-hook-form/phone-input.tsx index 22173ef7..c3b27fd2 100644 --- a/packages/components/src/remix-hook-form/phone-input.tsx +++ b/packages/components/src/remix-hook-form/phone-input.tsx @@ -1,5 +1,8 @@ -import * as React from 'react'; -import { PhoneInputField as BasePhoneInputField, type PhoneInputFieldProps as BasePhoneInputFieldProps } from '../ui/phone-input-field'; +import type * as React from 'react'; +import { + PhoneInputField as BasePhoneInputField, + type PhoneInputFieldProps as BasePhoneInputFieldProps, +} from '../ui/phone-input-field'; import { FormControl, FormDescription, FormLabel, FormMessage } from './form'; import { useRemixFormContext } from 'remix-hook-form'; diff --git a/packages/components/src/remix-hook-form/select.tsx b/packages/components/src/remix-hook-form/select.tsx index c2d0b648..7ced41ae 100644 --- a/packages/components/src/remix-hook-form/select.tsx +++ b/packages/components/src/remix-hook-form/select.tsx @@ -1,8 +1,8 @@ -import * as React from 'react'; +import type * as React from 'react'; import { useRemixFormContext } from 'remix-hook-form'; -import { FormControl, FormDescription, FormLabel, FormMessage } from './form'; import { FormField, FormItem } from '../ui/form'; -import { Select as UISelect, type SelectProps as UISelectProps, type SelectUIComponents } from '../ui/select'; +import { type SelectUIComponents, Select as UISelect, type SelectProps as UISelectProps } from '../ui/select'; +import { FormControl, FormDescription, FormLabel, FormMessage } from './form'; export interface SelectProps extends Omit { name: string; @@ -19,14 +19,7 @@ export interface SelectProps extends Omit; } -export function Select({ - name, - label, - description, - className, - components, - ...props -}: SelectProps) { +export function Select({ name, label, description, className, components, ...props }: SelectProps) { const { control } = useRemixFormContext(); return ( @@ -50,9 +43,7 @@ export function Select({ }} /> - {description && ( - {description} - )} + {description && {description}} )} diff --git a/packages/components/src/remix-hook-form/us-state-select.tsx b/packages/components/src/remix-hook-form/us-state-select.tsx index 332252b0..fe7e2a80 100644 --- a/packages/components/src/remix-hook-form/us-state-select.tsx +++ b/packages/components/src/remix-hook-form/us-state-select.tsx @@ -1,16 +1,8 @@ -import * as React from 'react'; -import { Select, type SelectProps } from './select'; import { US_STATES } from '../ui/data/us-states'; +import { Select, type SelectProps } from './select'; export type USStateSelectProps = Omit; export function USStateSelect(props: USStateSelectProps) { - return ( - ; } - diff --git a/packages/components/src/ui/canada-province-select.tsx b/packages/components/src/ui/canada-province-select.tsx index f004f78d..805439ab 100644 --- a/packages/components/src/ui/canada-province-select.tsx +++ b/packages/components/src/ui/canada-province-select.tsx @@ -1,16 +1,8 @@ -import * as React from 'react'; -import { Select, type SelectProps } from './select'; import { CANADA_PROVINCES } from './data/canada-provinces'; +import { Select, type SelectProps } from './select'; export type CanadaProvinceSelectProps = Omit; export function CanadaProvinceSelect(props: CanadaProvinceSelectProps) { - return ( - ; } - diff --git a/packages/components/src/ui/data/canada-provinces.ts b/packages/components/src/ui/data/canada-provinces.ts index 5f228dad..07291f2b 100644 --- a/packages/components/src/ui/data/canada-provinces.ts +++ b/packages/components/src/ui/data/canada-provinces.ts @@ -1,4 +1,4 @@ -import { SelectOption } from '../select'; +import type { SelectOption } from '../select'; export const CANADA_PROVINCES: SelectOption[] = [ { value: 'AB', label: 'Alberta' }, diff --git a/packages/components/src/ui/data/us-states.ts b/packages/components/src/ui/data/us-states.ts index bc4da2d0..22a4fa76 100644 --- a/packages/components/src/ui/data/us-states.ts +++ b/packages/components/src/ui/data/us-states.ts @@ -1,4 +1,4 @@ -import { SelectOption } from '../select'; +import type { SelectOption } from '../select'; export const US_STATES: SelectOption[] = [ { value: 'AL', label: 'Alabama' }, diff --git a/packages/components/src/ui/index.ts b/packages/components/src/ui/index.ts index 0c32f085..525146f7 100644 --- a/packages/components/src/ui/index.ts +++ b/packages/components/src/ui/index.ts @@ -33,4 +33,3 @@ export * from './us-state-select'; export * from './canada-province-select'; export * from './data/us-states'; export * from './data/canada-provinces'; - diff --git a/packages/components/src/ui/phone-input.tsx b/packages/components/src/ui/phone-input.tsx index 9c2f96bd..daa6b5e1 100644 --- a/packages/components/src/ui/phone-input.tsx +++ b/packages/components/src/ui/phone-input.tsx @@ -109,10 +109,10 @@ export const PhoneNumberInput = ({ const isNumberKey = NUMBER_KEY_REGEX.test(e.key); const isModifier = e.ctrlKey || e.metaKey || e.altKey; const allowed = ['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab', 'Home', 'End', 'Enter']; - + // Allow typing if we have fewer than 10 digits or if we have 11 digits but the first is '1' const isComplete = currentDigits.length >= 10 && !(currentDigits.length === 11 && currentDigits.startsWith('1')); - + if (!isModifier && isNumberKey && isComplete) { // Prevent adding more digits once 10-digit US number is complete e.preventDefault(); diff --git a/packages/components/src/ui/select.tsx b/packages/components/src/ui/select.tsx index a2512bcb..c5bebfc5 100644 --- a/packages/components/src/ui/select.tsx +++ b/packages/components/src/ui/select.tsx @@ -50,9 +50,11 @@ export function Select({ const popoverState = useOverlayTriggerState({}); const listboxId = React.useId(); const [query, setQuery] = React.useState(''); + const [activeIndex, setActiveIndex] = React.useState(0); const triggerRef = React.useRef(null); const popoverRef = React.useRef(null); const selectedItemRef = React.useRef(null); + const listContainerRef = React.useRef(null); // No need for JavaScript width measurement - Radix provides --radix-popover-trigger-width CSS variable // Scroll to selected item when dropdown opens @@ -72,13 +74,20 @@ export function Select({ [options, query], ); - // Candidate that would be chosen on Enter (exact match else first filtered) - const enterCandidate = React.useMemo(() => { - const q = query.trim().toLowerCase(); - if (filtered.length === 0) return undefined; - const exact = q ? filtered.find((o) => o.label.toLowerCase() === q) : undefined; - return exact ?? filtered[0]; - }, [filtered, query]); + // Reset activeIndex when filtered items change + React.useEffect(() => { + setActiveIndex(0); + }, [filtered]); + + // Scroll active item into view when activeIndex changes + React.useEffect(() => { + if (popoverState.isOpen && listContainerRef.current && filtered.length > 0) { + const activeElement = listContainerRef.current.querySelector(`[data-index="${activeIndex}"]`) as HTMLElement; + if (activeElement) { + activeElement.scrollIntoView({ block: 'nearest' }); + } + } + }, [activeIndex, popoverState.isOpen, filtered.length]); const Trigger = components?.Trigger || @@ -157,10 +166,11 @@ export function Select({ ref={(el) => { if (el) queueMicrotask(() => el.focus()); }} + aria-activedescendant={filtered.length > 0 ? `${listboxId}-option-${activeIndex}` : undefined} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); - const toSelect = enterCandidate; + const toSelect = filtered[activeIndex]; if (toSelect) { onValueChange?.(toSelect.value); setQuery(''); @@ -172,16 +182,24 @@ export function Select({ setQuery(''); popoverState.close(); triggerRef.current?.focus(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + if (filtered.length === 0) return; + setActiveIndex((prev) => Math.min(prev + 1, filtered.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (filtered.length === 0) return; + setActiveIndex((prev) => Math.max(prev - 1, 0)); } }} className="w-full h-9 rounded-md bg-white px-2 text-sm leading-none focus:ring-0 focus:outline-none border-0" />
-
    +
      {filtered.length === 0 &&
    • No results.
    • } - {filtered.map((option) => { + {filtered.map((option, index) => { const isSelected = option.value === value; - const isEnterCandidate = query.trim() !== '' && enterCandidate?.value === option.value && !isSelected; + const isActive = index === activeIndex; return (
    • for PopoverTrigger to ensure keyboard accessibility and focus management // biome-ignore lint/a11y/useAriaPropsForRole: using