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); - + // 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/packages/components/package.json b/packages/components/package.json index d8e85420..198fb363 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@lambdacurry/forms", - "version": "0.19.6", + "version": "0.19.7", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", 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..75475f3f 100644 --- a/packages/components/src/ui/select.tsx +++ b/packages/components/src/ui/select.tsx @@ -50,9 +50,12 @@ export function Select({ const popoverState = useOverlayTriggerState({}); const listboxId = React.useId(); const [query, setQuery] = React.useState(''); + const [activeIndex, setActiveIndex] = React.useState(0); + const [isInitialized, setIsInitialized] = React.useState(false); 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 +75,29 @@ 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 or dropdown opens + React.useEffect(() => { + if (popoverState.isOpen) { + setActiveIndex(0); + // Add a small delay to ensure the component is fully initialized + const timer = setTimeout(() => { + setIsInitialized(true); + }, 100); + return () => clearTimeout(timer); + } else { + setIsInitialized(false); + } + }, [filtered, popoverState.isOpen]); + + // 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 +176,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 +192,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" />
-