Skip to content
Merged
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
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
29 changes: 14 additions & 15 deletions apps/docs/src/remix-hook-form/select.test.tsx
Original file line number Diff line number Diff line change
@@ -1,80 +1,79 @@
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');
};

// 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');
};

// 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();
};

// 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();
};

2 changes: 1 addition & 1 deletion packages/components/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
12 changes: 2 additions & 10 deletions packages/components/src/remix-hook-form/canada-province-select.tsx
Original file line number Diff line number Diff line change
@@ -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<SelectProps, 'options'>;

export function CanadaProvinceSelect(props: CanadaProvinceSelectProps) {
return (
<Select
{...props}
options={CANADA_PROVINCES}
placeholder="Select a province"
/>
);
return <Select {...props} options={CANADA_PROVINCES} placeholder="Select a province" />;
}

1 change: 0 additions & 1 deletion packages/components/src/remix-hook-form/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,3 @@ export * from './use-data-table-url-state';
export * from './select';
export * from './us-state-select';
export * from './canada-province-select';

7 changes: 5 additions & 2 deletions packages/components/src/remix-hook-form/phone-input.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
19 changes: 5 additions & 14 deletions packages/components/src/remix-hook-form/select.tsx
Original file line number Diff line number Diff line change
@@ -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<UISelectProps, 'value' | 'onValueChange'> {
name: string;
Expand All @@ -19,14 +19,7 @@ export interface SelectProps extends Omit<UISelectProps, 'value' | 'onValueChang
>;
}

export function Select({
name,
label,
description,
className,
components,
...props
}: SelectProps) {
export function Select({ name, label, description, className, components, ...props }: SelectProps) {
const { control } = useRemixFormContext();

return (
Expand All @@ -50,9 +43,7 @@ export function Select({
}}
/>
</FormControl>
{description && (
<FormDescription Component={components?.FormDescription}>{description}</FormDescription>
)}
{description && <FormDescription Component={components?.FormDescription}>{description}</FormDescription>}
<FormMessage Component={components?.FormMessage} />
</FormItem>
)}
Expand Down
12 changes: 2 additions & 10 deletions packages/components/src/remix-hook-form/us-state-select.tsx
Original file line number Diff line number Diff line change
@@ -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<SelectProps, 'options'>;

export function USStateSelect(props: USStateSelectProps) {
return (
<Select
{...props}
options={US_STATES}
placeholder="Select a state"
/>
);
return <Select {...props} options={US_STATES} placeholder="Select a state" />;
}

12 changes: 2 additions & 10 deletions packages/components/src/ui/canada-province-select.tsx
Original file line number Diff line number Diff line change
@@ -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<SelectProps, 'options'>;

export function CanadaProvinceSelect(props: CanadaProvinceSelectProps) {
return (
<Select
options={CANADA_PROVINCES}
placeholder="Select a province"
{...props}
/>
);
return <Select options={CANADA_PROVINCES} placeholder="Select a province" {...props} />;
}

2 changes: 1 addition & 1 deletion packages/components/src/ui/data/canada-provinces.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SelectOption } from '../select';
import type { SelectOption } from '../select';

export const CANADA_PROVINCES: SelectOption[] = [
{ value: 'AB', label: 'Alberta' },
Expand Down
2 changes: 1 addition & 1 deletion packages/components/src/ui/data/us-states.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SelectOption } from '../select';
import type { SelectOption } from '../select';

export const US_STATES: SelectOption[] = [
{ value: 'AL', label: 'Alabama' },
Expand Down
1 change: 0 additions & 1 deletion packages/components/src/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,3 @@ export * from './us-state-select';
export * from './canada-province-select';
export * from './data/us-states';
export * from './data/canada-provinces';

4 changes: 2 additions & 2 deletions packages/components/src/ui/phone-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
55 changes: 43 additions & 12 deletions packages/components/src/ui/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement>(null);
const popoverRef = React.useRef<HTMLDivElement>(null);
const selectedItemRef = React.useRef<HTMLButtonElement>(null);
const listContainerRef = React.useRef<HTMLUListElement>(null);
// No need for JavaScript width measurement - Radix provides --radix-popover-trigger-width CSS variable

// Scroll to selected item when dropdown opens
Expand All @@ -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 ||
Expand Down Expand Up @@ -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('');
Expand All @@ -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"
/>
</div>
<ul className="max-h-[200px] overflow-y-auto rounded-md w-full">
<ul ref={listContainerRef} className="max-h-[200px] overflow-y-auto rounded-md w-full">
{filtered.length === 0 && <li className="px-3 py-2 text-sm text-gray-500">No results.</li>}
{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 (
<li key={option.value} className="list-none">
<Item
Expand All @@ -195,14 +223,17 @@ export function Select({
'w-full text-left cursor-pointer select-none py-3 px-3 transition-colors duration-150 flex items-center gap-2 rounded',
'text-gray-900',
isSelected ? 'bg-gray-100' : 'hover:bg-gray-100',
isEnterCandidate && 'bg-gray-50',
isActive && !isSelected && 'bg-gray-50',
itemClassName,
)}
// biome-ignore lint/a11y/useSemanticElements: using <button> for PopoverTrigger to ensure keyboard accessibility and focus management
// biome-ignore lint/a11y/useAriaPropsForRole: using <button> for PopoverTrigger to ensure keyboard accessibility and focus management
role="option"
aria-selected={isSelected}
id={`${listboxId}-option-${index}`}
data-selected={isSelected ? 'true' : 'false'}
data-active={isActive ? 'true' : 'false'}
data-index={index}
data-value={option.value}
data-testid={`select-option-${option.value}`}
selected={isSelected}
Expand Down
12 changes: 2 additions & 10 deletions packages/components/src/ui/us-state-select.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,8 @@
import * as React from 'react';
import { Select, type SelectProps } from './select';
import { US_STATES } from './data/us-states';
import { Select, type SelectProps } from './select';

export type USStateSelectProps = Omit<SelectProps, 'options'>;

export function USStateSelect(props: USStateSelectProps) {
return (
<Select
options={US_STATES}
placeholder="Select a state"
{...props}
/>
);
return <Select options={US_STATES} placeholder="Select a state" {...props} />;
}

Loading