From f01b323b474df3c3e135786a490eeeeb6a67fbb6 Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Sat, 16 Aug 2025 04:11:40 +0000 Subject: [PATCH 01/24] Add region select components for US states and Canada provinces - Create base RegionSelect component with shadcn styling - Add US_STATES and CANADA_PROVINCES data - Create convenience wrapper components for US states and Canada provinces - Create form-aware wrapper components for remix-hook-form - Add Storybook stories and tests Co-authored-by: Jake Ruesink --- .../remix-hook-form/region-select.stories.tsx | 141 +++++++++++ .../remix-hook-form/region-select.test.tsx | 80 +++++++ packages/components/package.json | 1 + .../canada-province-select.tsx | 16 ++ .../components/src/remix-hook-form/index.ts | 3 + .../src/remix-hook-form/region-select.tsx | 43 ++++ .../src/remix-hook-form/us-state-select.tsx | 16 ++ .../src/ui/canada-province-select.tsx | 16 ++ .../src/ui/data/canada-provinces.ts | 18 ++ packages/components/src/ui/data/us-states.ts | 56 +++++ packages/components/src/ui/index.ts | 5 + packages/components/src/ui/region-select.tsx | 73 ++++++ packages/components/src/ui/select.tsx | 220 ++++++++++++------ .../components/src/ui/us-state-select.tsx | 16 ++ yarn.lock | 40 ++++ 15 files changed, 679 insertions(+), 65 deletions(-) create mode 100644 apps/docs/src/remix-hook-form/region-select.stories.tsx create mode 100644 apps/docs/src/remix-hook-form/region-select.test.tsx create mode 100644 packages/components/src/remix-hook-form/canada-province-select.tsx create mode 100644 packages/components/src/remix-hook-form/region-select.tsx create mode 100644 packages/components/src/remix-hook-form/us-state-select.tsx create mode 100644 packages/components/src/ui/canada-province-select.tsx create mode 100644 packages/components/src/ui/data/canada-provinces.ts create mode 100644 packages/components/src/ui/data/us-states.ts create mode 100644 packages/components/src/ui/region-select.tsx create mode 100644 packages/components/src/ui/us-state-select.tsx diff --git a/apps/docs/src/remix-hook-form/region-select.stories.tsx b/apps/docs/src/remix-hook-form/region-select.stories.tsx new file mode 100644 index 00000000..90c54458 --- /dev/null +++ b/apps/docs/src/remix-hook-form/region-select.stories.tsx @@ -0,0 +1,141 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useFetcher } from '@remix-run/react'; +import { RemixFormProvider, useRemixForm } from 'remix-hook-form'; +import { Button } from '@lambdacurry/forms/ui/button'; +import { RegionSelect, USStateSelect, CanadaProvinceSelect } from '@lambdacurry/forms/remix-hook-form'; +import { US_STATES } from '@lambdacurry/forms/ui/data/us-states'; +import { CANADA_PROVINCES } from '@lambdacurry/forms/ui/data/canada-provinces'; +import { testUSStateSelection, testCanadaProvinceSelection, testFormSubmission, testValidationErrors } from './region-select.test'; + +const formSchema = z.object({ + state: z.string().min(1, 'Please select a state'), + province: z.string().min(1, 'Please select a province'), + region: z.string().min(1, 'Please select a region'), +}); + +type FormData = z.infer; + +function RegionSelectExample() { + const fetcher = useFetcher<{ message: string; selectedRegions: Record }>(); + + const methods = useRemixForm({ + resolver: zodResolver(formSchema), + defaultValues: { + state: '', + province: '', + region: '', + }, + fetcher, + submitConfig: { action: '/', method: 'post' }, + }); + + return ( + + +
+ + + + + +
+ + + + {fetcher.data?.selectedRegions && ( +
+

Selected regions:

+
    + {Object.entries(fetcher.data.selectedRegions).map(([key, value]) => ( +
  • + {key}: {value} +
  • + ))} +
+
+ )} +
+
+ ); +} + +export default { + title: 'RemixHookForm/RegionSelect', + component: RegionSelectExample, +} satisfies Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => , + parameters: { + docs: { + description: { + story: 'A region select component for selecting US states, Canadian provinces, or custom regions.', + }, + }, + }, + play: async (context) => { + await testValidationErrors(context); + }, +}; + +export const USStateSelectionTest: Story = { + render: () => , + parameters: { + docs: { + description: { + story: 'Test selecting a US state from the dropdown.', + }, + }, + }, + play: async (context) => { + await testUSStateSelection(context); + }, +}; + +export const CanadaProvinceSelectionTest: Story = { + render: () => , + parameters: { + docs: { + description: { + story: 'Test selecting a Canadian province from the dropdown.', + }, + }, + }, + play: async (context) => { + await testCanadaProvinceSelection(context); + }, +}; + +export const FormSubmissionTest: Story = { + render: () => , + parameters: { + docs: { + description: { + story: 'Test form submission with selected regions.', + }, + }, + }, + play: async (context) => { + await testFormSubmission(context); + }, +}; diff --git a/apps/docs/src/remix-hook-form/region-select.test.tsx b/apps/docs/src/remix-hook-form/region-select.test.tsx new file mode 100644 index 00000000..0c1475fb --- /dev/null +++ b/apps/docs/src/remix-hook-form/region-select.test.tsx @@ -0,0 +1,80 @@ +import { expect } from '@storybook/jest'; +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 = canvas.getByText('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 = canvas.getByText('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 = canvas.getByText('California'); + await userEvent.click(californiaOption); + + // Select a province + const provinceDropdown = canvas.getByLabelText('Canadian Province'); + await userEvent.click(provinceDropdown); + const ontarioOption = canvas.getByText('Ontario'); + await userEvent.click(ontarioOption); + + // Select a custom region + const regionDropdown = canvas.getByLabelText('Custom Region'); + await userEvent.click(regionDropdown); + const customOption = canvas.getByText('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(await canvas.findByText('Selected regions:')).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(await canvas.findByText('Please select a state')).toBeInTheDocument(); + await expect(await canvas.findByText('Please select a province')).toBeInTheDocument(); + await expect(await canvas.findByText('Please select a region')).toBeInTheDocument(); +}; + diff --git a/packages/components/package.json b/packages/components/package.json index 3638749c..d7c135ca 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -46,6 +46,7 @@ "@radix-ui/react-popover": "^1.1.13", "@radix-ui/react-radio-group": "^1.2.2", "@radix-ui/react-scroll-area": "^1.2.2", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.6", "@radix-ui/react-slider": "^1.3.4", "@radix-ui/react-slot": "^1.2.3", diff --git a/packages/components/src/remix-hook-form/canada-province-select.tsx b/packages/components/src/remix-hook-form/canada-province-select.tsx new file mode 100644 index 00000000..9561cc4d --- /dev/null +++ b/packages/components/src/remix-hook-form/canada-province-select.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { RegionSelect, type RegionSelectProps } from './region-select'; +import { CANADA_PROVINCES } from '../ui/data/canada-provinces'; + +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 dfa14017..24befb94 100644 --- a/packages/components/src/remix-hook-form/index.ts +++ b/packages/components/src/remix-hook-form/index.ts @@ -15,3 +15,6 @@ export * from './data-table-router-form'; export * from './data-table-router-parsers'; export * from './data-table-router-toolbar'; export * from './use-data-table-url-state'; +export * from './region-select'; +export * from './us-state-select'; +export * from './canada-province-select'; diff --git a/packages/components/src/remix-hook-form/region-select.tsx b/packages/components/src/remix-hook-form/region-select.tsx new file mode 100644 index 00000000..bd469231 --- /dev/null +++ b/packages/components/src/remix-hook-form/region-select.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; +import { useRemixFormContext } from 'remix-hook-form'; +import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from './form'; +import { RegionSelect as UIRegionSelect, type RegionSelectProps as UIRegionSelectProps } from '../ui/region-select'; + +export interface RegionSelectProps extends Omit { + name: string; + label?: string; + description?: string; + className?: string; +} + +export function RegionSelect({ + name, + label, + description, + className, + ...props +}: RegionSelectProps) { + const { control } = useRemixFormContext(); + + return ( + ( + + {label && {label}} + + + + {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 new file mode 100644 index 00000000..efcef37f --- /dev/null +++ b/packages/components/src/remix-hook-form/us-state-select.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { RegionSelect, type RegionSelectProps } from './region-select'; +import { US_STATES } from '../ui/data/us-states'; + +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 new file mode 100644 index 00000000..b6d05c6d --- /dev/null +++ b/packages/components/src/ui/canada-province-select.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { RegionSelect, type RegionSelectProps } from './region-select'; +import { CANADA_PROVINCES } from './data/canada-provinces'; + +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 new file mode 100644 index 00000000..4be03a08 --- /dev/null +++ b/packages/components/src/ui/data/canada-provinces.ts @@ -0,0 +1,18 @@ +import { RegionOption } from '../region-select'; + +export const CANADA_PROVINCES: RegionOption[] = [ + { value: 'AB', label: 'Alberta' }, + { value: 'BC', label: 'British Columbia' }, + { value: 'MB', label: 'Manitoba' }, + { value: 'NB', label: 'New Brunswick' }, + { value: 'NL', label: 'Newfoundland and Labrador' }, + { value: 'NT', label: 'Northwest Territories' }, + { value: 'NS', label: 'Nova Scotia' }, + { value: 'NU', label: 'Nunavut' }, + { value: 'ON', label: 'Ontario' }, + { value: 'PE', label: 'Prince Edward Island' }, + { value: 'QC', label: 'Quebec' }, + { value: 'SK', label: 'Saskatchewan' }, + { value: 'YT', label: 'Yukon' }, +]; + diff --git a/packages/components/src/ui/data/us-states.ts b/packages/components/src/ui/data/us-states.ts new file mode 100644 index 00000000..38b44ee3 --- /dev/null +++ b/packages/components/src/ui/data/us-states.ts @@ -0,0 +1,56 @@ +import { RegionOption } from '../region-select'; + +export const US_STATES: RegionOption[] = [ + { value: 'AL', label: 'Alabama' }, + { value: 'AK', label: 'Alaska' }, + { value: 'AZ', label: 'Arizona' }, + { value: 'AR', label: 'Arkansas' }, + { value: 'CA', label: 'California' }, + { value: 'CO', label: 'Colorado' }, + { value: 'CT', label: 'Connecticut' }, + { value: 'DE', label: 'Delaware' }, + { value: 'FL', label: 'Florida' }, + { value: 'GA', label: 'Georgia' }, + { value: 'HI', label: 'Hawaii' }, + { value: 'ID', label: 'Idaho' }, + { value: 'IL', label: 'Illinois' }, + { value: 'IN', label: 'Indiana' }, + { value: 'IA', label: 'Iowa' }, + { value: 'KS', label: 'Kansas' }, + { value: 'KY', label: 'Kentucky' }, + { value: 'LA', label: 'Louisiana' }, + { value: 'ME', label: 'Maine' }, + { value: 'MD', label: 'Maryland' }, + { value: 'MA', label: 'Massachusetts' }, + { value: 'MI', label: 'Michigan' }, + { value: 'MN', label: 'Minnesota' }, + { value: 'MS', label: 'Mississippi' }, + { value: 'MO', label: 'Missouri' }, + { value: 'MT', label: 'Montana' }, + { value: 'NE', label: 'Nebraska' }, + { value: 'NV', label: 'Nevada' }, + { value: 'NH', label: 'New Hampshire' }, + { value: 'NJ', label: 'New Jersey' }, + { value: 'NM', label: 'New Mexico' }, + { value: 'NY', label: 'New York' }, + { value: 'NC', label: 'North Carolina' }, + { value: 'ND', label: 'North Dakota' }, + { value: 'OH', label: 'Ohio' }, + { value: 'OK', label: 'Oklahoma' }, + { value: 'OR', label: 'Oregon' }, + { value: 'PA', label: 'Pennsylvania' }, + { value: 'RI', label: 'Rhode Island' }, + { value: 'SC', label: 'South Carolina' }, + { value: 'SD', label: 'South Dakota' }, + { value: 'TN', label: 'Tennessee' }, + { value: 'TX', label: 'Texas' }, + { value: 'UT', label: 'Utah' }, + { value: 'VT', label: 'Vermont' }, + { value: 'VA', label: 'Virginia' }, + { value: 'WA', label: 'Washington' }, + { value: 'WV', label: 'West Virginia' }, + { value: 'WI', label: 'Wisconsin' }, + { value: 'WY', label: 'Wyoming' }, + { value: 'DC', label: 'District of Columbia' }, +]; + diff --git a/packages/components/src/ui/index.ts b/packages/components/src/ui/index.ts index aa00ae24..f2117e4b 100644 --- a/packages/components/src/ui/index.ts +++ b/packages/components/src/ui/index.ts @@ -30,3 +30,8 @@ export * from './badge'; export * from './command'; export * from './select'; export * from './separator'; +export * from './region-select'; +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/region-select.tsx b/packages/components/src/ui/region-select.tsx new file mode 100644 index 00000000..aae3c57d --- /dev/null +++ b/packages/components/src/ui/region-select.tsx @@ -0,0 +1,73 @@ +import * as React from 'react'; +import { Check, ChevronDown } from 'lucide-react'; +import { cn } from './utils'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from './select'; + +export interface RegionOption { + label: string; + value: string; +} + +export interface RegionSelectProps { + options: RegionOption[]; + value?: string; + onValueChange?: (value: string) => void; + placeholder?: string; + disabled?: boolean; + className?: string; + contentClassName?: string; + itemClassName?: string; +} + +export function RegionSelect({ + options, + value, + onValueChange, + placeholder = 'Select a region', + disabled = false, + className, + contentClassName, + itemClassName, +}: RegionSelectProps) { + return ( + + ); +} + diff --git a/packages/components/src/ui/select.tsx b/packages/components/src/ui/select.tsx index 10dcb895..1d3e56f6 100644 --- a/packages/components/src/ui/select.tsx +++ b/packages/components/src/ui/select.tsx @@ -1,74 +1,164 @@ -import { Check, ChevronDown } from 'lucide-react'; import * as React from 'react'; +import * as SelectPrimitive from '@radix-ui/react-select'; +import { Check, ChevronDown, ChevronUp } from 'lucide-react'; -import { Button } from './button'; -import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './command'; -import { Popover, PopoverContent, PopoverTrigger } from './popover'; import { cn } from './utils'; -export interface SelectOption { - label: string; - value: string; -} +const Select = SelectPrimitive.Root; -interface SelectProps { - options: SelectOption[]; - value?: string; - onValueChange?: (value: string) => void; - placeholder?: string; - disabled?: boolean; - className?: string; -} +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1', + className + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; -export function Select({ - options, - value, - onValueChange, - placeholder = 'Select an option', - disabled = false, - className, -}: SelectProps) { - const [open, setOpen] = React.useState(false); +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = 'popper', ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; - const selectedOption = options.find((option) => option.value === value); +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; - return ( - - - - - - - - No option found. - - - {options.map((option) => ( - { - onValueChange?.(option.value); - setOpen(false); - }} - className="flex items-center" - > - - {option.label} - - ))} - - - - - - ); +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; + +export interface SelectOption { + label: string; + value: string; } + diff --git a/packages/components/src/ui/us-state-select.tsx b/packages/components/src/ui/us-state-select.tsx new file mode 100644 index 00000000..e2d42743 --- /dev/null +++ b/packages/components/src/ui/us-state-select.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { RegionSelect, type RegionSelectProps } from './region-select'; +import { US_STATES } from './data/us-states'; + +export type USStateSelectProps = Omit; + +export function USStateSelect(props: USStateSelectProps) { + return ( + + ); +} + diff --git a/yarn.lock b/yarn.lock index c264a3c2..f4344485 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1621,6 +1621,7 @@ __metadata: "@radix-ui/react-popover": "npm:^1.1.13" "@radix-ui/react-radio-group": "npm:^1.2.2" "@radix-ui/react-scroll-area": "npm:^1.2.2" + "@radix-ui/react-select": "npm:^2.2.6" "@radix-ui/react-separator": "npm:^1.1.6" "@radix-ui/react-slider": "npm:^1.3.4" "@radix-ui/react-slot": "npm:^1.2.3" @@ -2441,6 +2442,45 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-select@npm:^2.2.6": + version: 2.2.6 + resolution: "@radix-ui/react-select@npm:2.2.6" + dependencies: + "@radix-ui/number": "npm:1.1.1" + "@radix-ui/primitive": "npm:1.1.3" + "@radix-ui/react-collection": "npm:1.1.7" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-direction": "npm:1.1.1" + "@radix-ui/react-dismissable-layer": "npm:1.1.11" + "@radix-ui/react-focus-guards": "npm:1.1.3" + "@radix-ui/react-focus-scope": "npm:1.1.7" + "@radix-ui/react-id": "npm:1.1.1" + "@radix-ui/react-popper": "npm:1.2.8" + "@radix-ui/react-portal": "npm:1.1.9" + "@radix-ui/react-primitive": "npm:2.1.3" + "@radix-ui/react-slot": "npm:1.2.3" + "@radix-ui/react-use-callback-ref": "npm:1.1.1" + "@radix-ui/react-use-controllable-state": "npm:1.2.2" + "@radix-ui/react-use-layout-effect": "npm:1.1.1" + "@radix-ui/react-use-previous": "npm:1.1.1" + "@radix-ui/react-visually-hidden": "npm:1.2.3" + aria-hidden: "npm:^1.2.4" + react-remove-scroll: "npm:^2.6.3" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/34b2492589c3a4b118a03900d622640033630f30ac93c4a69b3701513117607f4ac3a0d9dd3cad39caa8b6495660f71f3aa9d0074d4eb4dac6804dc0b8408deb + languageName: node + linkType: hard + "@radix-ui/react-separator@npm:^1.1.6": version: 1.1.7 resolution: "@radix-ui/react-separator@npm:1.1.7" From 4b4c5fbc25873668c1606a0febb99fa870ea295b Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Sat, 16 Aug 2025 04:18:10 +0000 Subject: [PATCH 02/24] Fix: Replace @remix-run/react dependency with mock fetcher in region-select stories - Removed direct dependency on @remix-run/react - Created a mock fetcher implementation for Storybook - Fixed build error in Storybook --- .../remix-hook-form/region-select.stories.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/docs/src/remix-hook-form/region-select.stories.tsx b/apps/docs/src/remix-hook-form/region-select.stories.tsx index 90c54458..74826b37 100644 --- a/apps/docs/src/remix-hook-form/region-select.stories.tsx +++ b/apps/docs/src/remix-hook-form/region-select.stories.tsx @@ -1,7 +1,6 @@ import { Meta, StoryObj } from '@storybook/react'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; -import { useFetcher } from '@remix-run/react'; import { RemixFormProvider, useRemixForm } from 'remix-hook-form'; import { Button } from '@lambdacurry/forms/ui/button'; import { RegionSelect, USStateSelect, CanadaProvinceSelect } from '@lambdacurry/forms/remix-hook-form'; @@ -9,6 +8,18 @@ import { US_STATES } from '@lambdacurry/forms/ui/data/us-states'; import { CANADA_PROVINCES } from '@lambdacurry/forms/ui/data/canada-provinces'; import { testUSStateSelection, testCanadaProvinceSelection, testFormSubmission, testValidationErrors } from './region-select.test'; +// Create a mock fetcher to replace the Remix useFetcher +const createMockFetcher = () => { + return { + Form: ({ children, onSubmit }: { children: React.ReactNode; onSubmit: (e: React.FormEvent) => void }) => ( +
{children}
+ ), + data: null, + state: 'idle', + submit: () => {}, + }; +}; + const formSchema = z.object({ state: z.string().min(1, 'Please select a state'), province: z.string().min(1, 'Please select a province'), @@ -18,7 +29,8 @@ const formSchema = z.object({ type FormData = z.infer; function RegionSelectExample() { - const fetcher = useFetcher<{ message: string; selectedRegions: Record }>(); + // Replace useFetcher with our mock + const fetcher = createMockFetcher(); const methods = useRemixForm({ resolver: zodResolver(formSchema), @@ -27,7 +39,7 @@ function RegionSelectExample() { province: '', region: '', }, - fetcher, + fetcher: fetcher as any, // Cast to any to satisfy TypeScript submitConfig: { action: '/', method: 'post' }, }); From 2277bb601d62f453f319e42b842a90ac5dba2f32 Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Sat, 16 Aug 2025 04:24:45 +0000 Subject: [PATCH 03/24] Add @remix-run/react and react-dom dependencies --- package.json | 3 ++ yarn.lock | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 69d36fbb..514bc240 100644 --- a/package.json +++ b/package.json @@ -21,10 +21,13 @@ }, "devDependencies": { "@biomejs/biome": "^1.9.4", + "@types/react-dom": "^19", "turbo": "^2.3.3" }, "dependencies": { "@changesets/cli": "^2.27.11", + "@remix-run/react": "^2.17.0", + "react-dom": "^19.1.1", "react-phone-number-input": "^3.4.12", "react-router-dom": "^7.6.2" }, diff --git a/yarn.lock b/yarn.lock index f4344485..84384726 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2848,6 +2848,53 @@ __metadata: languageName: node linkType: hard +"@remix-run/react@npm:^2.17.0": + version: 2.17.0 + resolution: "@remix-run/react@npm:2.17.0" + dependencies: + "@remix-run/router": "npm:1.23.0" + "@remix-run/server-runtime": "npm:2.17.0" + react-router: "npm:6.30.0" + react-router-dom: "npm:6.30.0" + turbo-stream: "npm:2.4.1" + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + typescript: ^5.1.0 + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/2263fd0fcee998e450abfced3897269eefccdbfa653dac45ebce1fb31acf58704b5c93ff34db8b0ba4da00632a7830a1df2e281a6a80f1ef33e7e626791ac29f + languageName: node + linkType: hard + +"@remix-run/router@npm:1.23.0": + version: 1.23.0 + resolution: "@remix-run/router@npm:1.23.0" + checksum: 10c0/eaef5cb46a1e413f7d1019a75990808307e08e53a39d4cf69c339432ddc03143d725decef3d6b9b5071b898da07f72a4a57c4e73f787005fcf10162973d8d7d7 + languageName: node + linkType: hard + +"@remix-run/server-runtime@npm:2.17.0": + version: 2.17.0 + resolution: "@remix-run/server-runtime@npm:2.17.0" + dependencies: + "@remix-run/router": "npm:1.23.0" + "@types/cookie": "npm:^0.6.0" + "@web3-storage/multipart-parser": "npm:^1.0.0" + cookie: "npm:^0.7.2" + set-cookie-parser: "npm:^2.4.8" + source-map: "npm:^0.7.3" + turbo-stream: "npm:2.4.1" + peerDependencies: + typescript: ^5.1.0 + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/4f319ac7a317a54602a5cfac0a54c579daff71f327d85daeafa0d2409ea38db326a36141f582b06a641c34cc8ceeb3908e51ba681ac747a51baea98daff7299f + languageName: node + linkType: hard + "@rolldown/pluginutils@npm:1.0.0-beta.27": version: 1.0.0-beta.27 resolution: "@rolldown/pluginutils@npm:1.0.0-beta.27" @@ -3823,6 +3870,13 @@ __metadata: languageName: node linkType: hard +"@types/cookie@npm:^0.6.0": + version: 0.6.0 + resolution: "@types/cookie@npm:0.6.0" + checksum: 10c0/5b326bd0188120fb32c0be086b141b1481fec9941b76ad537f9110e10d61ee2636beac145463319c71e4be67a17e85b81ca9e13ceb6e3bb63b93d16824d6c149 + languageName: node + linkType: hard + "@types/deep-eql@npm:*": version: 4.0.2 resolution: "@types/deep-eql@npm:4.0.2" @@ -3925,6 +3979,15 @@ __metadata: languageName: node linkType: hard +"@types/react-dom@npm:^19": + version: 19.1.7 + resolution: "@types/react-dom@npm:19.1.7" + peerDependencies: + "@types/react": ^19.0.0 + checksum: 10c0/8db5751c1567552fe4e1ece9f5823b682f2994ec8d30ed34ba0ef984e3c8ace1435f8be93d02f55c350147e78ac8c4dbcd8ed2c3b6a60f575bc5374f588c51c9 + languageName: node + linkType: hard + "@types/react@npm:^19.0.0": version: 19.1.10 resolution: "@types/react@npm:19.1.10" @@ -4349,6 +4412,13 @@ __metadata: languageName: node linkType: hard +"@web3-storage/multipart-parser@npm:^1.0.0": + version: 1.0.0 + resolution: "@web3-storage/multipart-parser@npm:1.0.0" + checksum: 10c0/1cdf5bbb5a40d151a4c6ebf00e7e2f1075bd91d08d5c7259e683a4b5d31e697ad594024644dcf547f297fdef39d39b75a7edb2b234720f80e8e860284022aa96 + languageName: node + linkType: hard + "abbrev@npm:^3.0.0": version: 3.0.1 resolution: "abbrev@npm:3.0.1" @@ -5271,6 +5341,13 @@ __metadata: languageName: node linkType: hard +"cookie@npm:^0.7.2": + version: 0.7.2 + resolution: "cookie@npm:0.7.2" + checksum: 10c0/9596e8ccdbf1a3a88ae02cf5ee80c1c50959423e1022e4e60b91dd87c622af1da309253d8abdb258fb5e3eacb4f08e579dc58b4897b8087574eee0fd35dfa5d2 + languageName: node + linkType: hard + "cookie@npm:^1.0.1": version: 1.0.2 resolution: "cookie@npm:1.0.2" @@ -6269,6 +6346,9 @@ __metadata: dependencies: "@biomejs/biome": "npm:^1.9.4" "@changesets/cli": "npm:^2.27.11" + "@remix-run/react": "npm:^2.17.0" + "@types/react-dom": "npm:^19" + react-dom: "npm:^19.1.1" react-phone-number-input: "npm:^3.4.12" react-router-dom: "npm:^7.6.2" turbo: "npm:^2.3.3" @@ -9419,7 +9499,7 @@ __metadata: languageName: node linkType: hard -"react-dom@npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0": +"react-dom@npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0, react-dom@npm:^19.1.1": version: 19.1.1 resolution: "react-dom@npm:19.1.1" dependencies: @@ -9525,6 +9605,19 @@ __metadata: languageName: node linkType: hard +"react-router-dom@npm:6.30.0": + version: 6.30.0 + resolution: "react-router-dom@npm:6.30.0" + dependencies: + "@remix-run/router": "npm:1.23.0" + react-router: "npm:6.30.0" + peerDependencies: + react: ">=16.8" + react-dom: ">=16.8" + checksum: 10c0/262954ba894d6a241ceda5f61098f7d6a292d0018a6ebb9c9c67425b7deb6e59b6191a9233a03d38e287e60f7ac3702e9e84c8e20b39a6487698fe088b71e27a + languageName: node + linkType: hard + "react-router-dom@npm:^7.6.2, react-router-dom@npm:^7.6.3": version: 7.8.0 resolution: "react-router-dom@npm:7.8.0" @@ -9537,6 +9630,17 @@ __metadata: languageName: node linkType: hard +"react-router@npm:6.30.0": + version: 6.30.0 + resolution: "react-router@npm:6.30.0" + dependencies: + "@remix-run/router": "npm:1.23.0" + peerDependencies: + react: ">=16.8" + checksum: 10c0/e6f20cf5c47ec057a057a4cfb9a55983d0a5b4b3314d20e07f0a70e59e004f51778d4dac415aee1e4e64db69cc4cd72e5acf8fd60dcf07d909895b8863b0b023 + languageName: node + linkType: hard + "react-router@npm:7.8.0, react-router@npm:^7.6.1, react-router@npm:^7.6.3": version: 7.8.0 resolution: "react-router@npm:7.8.0" @@ -9951,7 +10055,7 @@ __metadata: languageName: node linkType: hard -"set-cookie-parser@npm:^2.6.0": +"set-cookie-parser@npm:^2.4.8, set-cookie-parser@npm:^2.6.0": version: 2.7.1 resolution: "set-cookie-parser@npm:2.7.1" checksum: 10c0/060c198c4c92547ac15988256f445eae523f57f2ceefeccf52d30d75dedf6bff22b9c26f756bd44e8e560d44ff4ab2130b178bd2e52ef5571bf7be3bd7632d9a @@ -10145,6 +10249,13 @@ __metadata: languageName: node linkType: hard +"source-map@npm:^0.7.3": + version: 0.7.6 + resolution: "source-map@npm:0.7.6" + checksum: 10c0/59f6f05538539b274ba771d2e9e32f6c65451982510564438e048bc1352f019c6efcdc6dd07909b1968144941c14015c2c7d4369fb7c4d7d53ae769716dcc16c + languageName: node + linkType: hard + "spawn-wrap@npm:^2.0.0": version: 2.0.0 resolution: "spawn-wrap@npm:2.0.0" @@ -10695,6 +10806,13 @@ __metadata: languageName: node linkType: hard +"turbo-stream@npm:2.4.1": + version: 2.4.1 + resolution: "turbo-stream@npm:2.4.1" + checksum: 10c0/c93470c732787882b0085f23db1802a28c99182e4f3a39906409ab0221405b4cde3fc6e40b1f4f30a9e804e6843b81af58637e7cab3ae575781fb96875eb041f + languageName: node + linkType: hard + "turbo-stream@npm:^3.1.0": version: 3.1.0 resolution: "turbo-stream@npm:3.1.0" From 93abf0cf7af14c1f67eb1d728e8f3da317ceb2ca Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Sat, 16 Aug 2025 04:28:10 +0000 Subject: [PATCH 04/24] Fix build errors: - Update FormField import in region-select.tsx - Replace @storybook/jest with @storybook/test Co-authored-by: Jake Ruesink --- apps/docs/src/remix-hook-form/region-select.test.tsx | 3 +-- packages/components/src/remix-hook-form/region-select.tsx | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/docs/src/remix-hook-form/region-select.test.tsx b/apps/docs/src/remix-hook-form/region-select.test.tsx index 0c1475fb..dcb76207 100644 --- a/apps/docs/src/remix-hook-form/region-select.test.tsx +++ b/apps/docs/src/remix-hook-form/region-select.test.tsx @@ -1,4 +1,4 @@ -import { expect } from '@storybook/jest'; +import { expect } from '@storybook/test'; import { userEvent, within } from '@storybook/testing-library'; import { StoryContext } from '@storybook/react'; @@ -77,4 +77,3 @@ export const testValidationErrors = async ({ canvasElement }: StoryContext) => { await expect(await canvas.findByText('Please select a province')).toBeInTheDocument(); await expect(await canvas.findByText('Please select a region')).toBeInTheDocument(); }; - diff --git a/packages/components/src/remix-hook-form/region-select.tsx b/packages/components/src/remix-hook-form/region-select.tsx index bd469231..7ad4d3c8 100644 --- a/packages/components/src/remix-hook-form/region-select.tsx +++ b/packages/components/src/remix-hook-form/region-select.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { useRemixFormContext } from 'remix-hook-form'; -import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from './form'; +import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from './form'; +import { FormField } from '../ui/form'; import { RegionSelect as UIRegionSelect, type RegionSelectProps as UIRegionSelectProps } from '../ui/region-select'; export interface RegionSelectProps extends Omit { @@ -40,4 +41,3 @@ export function RegionSelect({ /> ); } - From 9dcee12e82613644e07ed8b213dea7aeac1a3fc7 Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Sat, 16 Aug 2025 04:36:45 +0000 Subject: [PATCH 05/24] Fix build errors: - Update FormItem import in region-select.tsx - Replace @storybook/jest with @storybook/test Co-authored-by: Jake Ruesink --- packages/components/src/remix-hook-form/region-select.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/components/src/remix-hook-form/region-select.tsx b/packages/components/src/remix-hook-form/region-select.tsx index 7ad4d3c8..7b0b65e6 100644 --- a/packages/components/src/remix-hook-form/region-select.tsx +++ b/packages/components/src/remix-hook-form/region-select.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { useRemixFormContext } from 'remix-hook-form'; -import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from './form'; -import { FormField } from '../ui/form'; +import { FormControl, FormDescription, FormLabel, FormMessage } from './form'; +import { FormField, FormItem } from '../ui/form'; import { RegionSelect as UIRegionSelect, type RegionSelectProps as UIRegionSelectProps } from '../ui/region-select'; export interface RegionSelectProps extends Omit { From 16c07e86ad23d2a478ee89fb0ae5b551e25fd3e5 Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Sat, 16 Aug 2025 04:44:04 +0000 Subject: [PATCH 06/24] Update Storybook stories to match repository patterns: - Use withReactRouterStubDecorator instead of mock fetcher - Update story structure to match other components - Improve test organization with step function - Add source code example in docs Co-authored-by: Jake Ruesink --- .../remix-hook-form/region-select.stories.tsx | 235 +++++++++++++++--- .../remix-hook-form/region-select.test.tsx | 19 +- 2 files changed, 204 insertions(+), 50 deletions(-) diff --git a/apps/docs/src/remix-hook-form/region-select.stories.tsx b/apps/docs/src/remix-hook-form/region-select.stories.tsx index 74826b37..4a298bbe 100644 --- a/apps/docs/src/remix-hook-form/region-select.stories.tsx +++ b/apps/docs/src/remix-hook-form/region-select.stories.tsx @@ -1,24 +1,14 @@ -import { Meta, StoryObj } from '@storybook/react'; -import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; -import { RemixFormProvider, useRemixForm } from 'remix-hook-form'; -import { Button } from '@lambdacurry/forms/ui/button'; import { RegionSelect, USStateSelect, CanadaProvinceSelect } from '@lambdacurry/forms/remix-hook-form'; import { US_STATES } from '@lambdacurry/forms/ui/data/us-states'; import { CANADA_PROVINCES } from '@lambdacurry/forms/ui/data/canada-provinces'; -import { testUSStateSelection, testCanadaProvinceSelection, testFormSubmission, testValidationErrors } from './region-select.test'; - -// Create a mock fetcher to replace the Remix useFetcher -const createMockFetcher = () => { - return { - Form: ({ children, onSubmit }: { children: React.ReactNode; onSubmit: (e: React.FormEvent) => void }) => ( -
{children}
- ), - data: null, - state: 'idle', - submit: () => {}, - }; -}; +import { Button } from '@lambdacurry/forms/ui/button'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent, within } from '@storybook/test'; +import { type ActionFunctionArgs, useFetcher } from 'react-router'; +import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form'; +import { z } from 'zod'; +import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub'; const formSchema = z.object({ state: z.string().min(1, 'Please select a state'), @@ -28,9 +18,8 @@ const formSchema = z.object({ type FormData = z.infer; -function RegionSelectExample() { - // Replace useFetcher with our mock - const fetcher = createMockFetcher(); +const RegionSelectExample = () => { + const fetcher = useFetcher<{ message: string; selectedRegions: Record }>(); const methods = useRemixForm({ resolver: zodResolver(formSchema), @@ -39,7 +28,7 @@ function RegionSelectExample() { province: '', region: '', }, - fetcher: fetcher as any, // Cast to any to satisfy TypeScript + fetcher, submitConfig: { action: '/', method: 'post' }, }); @@ -87,31 +76,141 @@ function RegionSelectExample() { ); -} +}; + +const handleFormSubmission = async (request: Request) => { + const { data, errors } = await getValidatedFormData(request, zodResolver(formSchema)); + + if (errors) { + return { errors }; + } + + return { + message: 'Form submitted successfully', + selectedRegions: { + state: data.state, + province: data.province, + region: data.region + } + }; +}; -export default { +const meta: Meta = { title: 'RemixHookForm/RegionSelect', - component: RegionSelectExample, -} satisfies Meta; + component: RegionSelect, + parameters: { layout: 'centered' }, + tags: ['autodocs'], + decorators: [ + withReactRouterStubDecorator({ + routes: [ + { + path: '/', + Component: RegionSelectExample, + action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request), + }, + ], + }), + ], +} satisfies Meta; -type Story = StoryObj; +export default meta; +type Story = StoryObj; export const Default: Story = { - render: () => , parameters: { docs: { description: { story: 'A region select component for selecting US states, Canadian provinces, or custom regions.', }, + source: { + code: ` +const formSchema = z.object({ + state: z.string().min(1, 'Please select a state'), + province: z.string().min(1, 'Please select a province'), + region: z.string().min(1, 'Please select a region'), +}); + +const RegionSelectExample = () => { + const fetcher = useFetcher<{ message: string; selectedRegions: Record }>(); + + const methods = useRemixForm({ + resolver: zodResolver(formSchema), + defaultValues: { + state: '', + province: '', + region: '', + }, + fetcher, + submitConfig: { action: '/', method: 'post' }, + }); + + return ( + + +
+ + + + + +
+ + +
+
+ ); +};`, + }, }, }, - play: async (context) => { - await testValidationErrors(context); + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step('Verify initial state', async () => { + // Verify all selects are empty initially + const stateSelect = canvas.getByLabelText('US State'); + const provinceSelect = canvas.getByLabelText('Canadian Province'); + const regionSelect = canvas.getByLabelText('Custom Region'); + + expect(stateSelect).toHaveTextContent('Select a state'); + expect(provinceSelect).toHaveTextContent('Select a province'); + expect(regionSelect).toHaveTextContent('Select a custom region'); + + // Verify submit button is present + const submitButton = canvas.getByRole('button', { name: 'Submit' }); + expect(submitButton).toBeInTheDocument(); + }); + + await step('Test validation errors on invalid submission', async () => { + // Submit form without selecting any options + const submitButton = canvas.getByRole('button', { name: 'Submit' }); + await userEvent.click(submitButton); + + // Verify validation error messages appear + 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(); + }); }, }; -export const USStateSelectionTest: Story = { - render: () => , +export const USStateSelection: Story = { parameters: { docs: { description: { @@ -119,13 +218,25 @@ export const USStateSelectionTest: Story = { }, }, }, - play: async (context) => { - await testUSStateSelection(context); + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step('Select a US state', async () => { + // Find and click the US state dropdown + const stateSelect = canvas.getByLabelText('US State'); + await userEvent.click(stateSelect); + + // Wait for dropdown to open and select California + const californiaOption = await canvas.findByText('California'); + await userEvent.click(californiaOption); + + // Verify the selection + expect(stateSelect).toHaveTextContent('California'); + }); }, }; -export const CanadaProvinceSelectionTest: Story = { - render: () => , +export const CanadaProvinceSelection: Story = { parameters: { docs: { description: { @@ -133,13 +244,25 @@ export const CanadaProvinceSelectionTest: Story = { }, }, }, - play: async (context) => { - await testCanadaProvinceSelection(context); + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step('Select a Canadian province', async () => { + // Find and click the Canada province dropdown + const provinceSelect = canvas.getByLabelText('Canadian Province'); + await userEvent.click(provinceSelect); + + // Wait for dropdown to open and select Ontario + const ontarioOption = await canvas.findByText('Ontario'); + await userEvent.click(ontarioOption); + + // Verify the selection + expect(provinceSelect).toHaveTextContent('Ontario'); + }); }, }; -export const FormSubmissionTest: Story = { - render: () => , +export const FormSubmission: Story = { parameters: { docs: { description: { @@ -147,7 +270,37 @@ export const FormSubmissionTest: Story = { }, }, }, - play: async (context) => { - await testFormSubmission(context); + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step('Select all regions', async () => { + // Select a state + const stateSelect = canvas.getByLabelText('US State'); + await userEvent.click(stateSelect); + const californiaOption = await canvas.findByText('California'); + await userEvent.click(californiaOption); + + // Select a province + const provinceSelect = canvas.getByLabelText('Canadian Province'); + await userEvent.click(provinceSelect); + const ontarioOption = await canvas.findByText('Ontario'); + await userEvent.click(ontarioOption); + + // Select a custom region + const regionSelect = canvas.getByLabelText('Custom Region'); + await userEvent.click(regionSelect); + const customOption = await canvas.findByText('New York'); + await userEvent.click(customOption); + }); + + await step('Submit the form', async () => { + // Submit the form + const submitButton = canvas.getByRole('button', { name: 'Submit' }); + await userEvent.click(submitButton); + + // Verify the submission result + await expect(canvas.findByText('Selected regions:')).resolves.toBeInTheDocument(); + }); }, }; + diff --git a/apps/docs/src/remix-hook-form/region-select.test.tsx b/apps/docs/src/remix-hook-form/region-select.test.tsx index dcb76207..7e502e7e 100644 --- a/apps/docs/src/remix-hook-form/region-select.test.tsx +++ b/apps/docs/src/remix-hook-form/region-select.test.tsx @@ -11,7 +11,7 @@ export const testUSStateSelection = async ({ canvasElement }: StoryContext) => { await userEvent.click(stateDropdown); // Select a state (e.g., California) - const californiaOption = canvas.getByText('California'); + const californiaOption = await canvas.findByText('California'); await userEvent.click(californiaOption); // Verify the selection @@ -27,7 +27,7 @@ export const testCanadaProvinceSelection = async ({ canvasElement }: StoryContex await userEvent.click(provinceDropdown); // Select a province (e.g., Ontario) - const ontarioOption = canvas.getByText('Ontario'); + const ontarioOption = await canvas.findByText('Ontario'); await userEvent.click(ontarioOption); // Verify the selection @@ -41,19 +41,19 @@ export const testFormSubmission = async ({ canvasElement }: StoryContext) => { // Select a state const stateDropdown = canvas.getByLabelText('US State'); await userEvent.click(stateDropdown); - const californiaOption = canvas.getByText('California'); + 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 = canvas.getByText('Ontario'); + 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 = canvas.getByText('New York'); + const customOption = await canvas.findByText('New York'); await userEvent.click(customOption); // Submit the form @@ -61,7 +61,7 @@ export const testFormSubmission = async ({ canvasElement }: StoryContext) => { await userEvent.click(submitButton); // Verify the submission (mock response would be shown) - await expect(await canvas.findByText('Selected regions:')).toBeInTheDocument(); + await expect(canvas.findByText('Selected regions:')).resolves.toBeInTheDocument(); }; // Test validation errors @@ -73,7 +73,8 @@ export const testValidationErrors = async ({ canvasElement }: StoryContext) => { await userEvent.click(submitButton); // Verify error messages - await expect(await canvas.findByText('Please select a state')).toBeInTheDocument(); - await expect(await canvas.findByText('Please select a province')).toBeInTheDocument(); - await expect(await canvas.findByText('Please select a region')).toBeInTheDocument(); + 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(); }; + From 98c7b1ccd1ffba2e55d81a29da4376a2fa3194c8 Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Sat, 16 Aug 2025 05:03:12 +0000 Subject: [PATCH 07/24] Update region-select component to match 360training style: - Add search functionality - Remove check icon from trigger - Improve dropdown styling - Add react-stately dependency Co-authored-by: Jake Ruesink --- package.json | 3 +- packages/components/src/ui/region-select.tsx | 146 +++- yarn.lock | 679 ++++++++++++++++++- 3 files changed, 792 insertions(+), 36 deletions(-) diff --git a/package.json b/package.json index 514bc240..9815adfe 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "@remix-run/react": "^2.17.0", "react-dom": "^19.1.1", "react-phone-number-input": "^3.4.12", - "react-router-dom": "^7.6.2" + "react-router-dom": "^7.6.2", + "react-stately": "^3.40.0" }, "packageManager": "yarn@4.9.1" } diff --git a/packages/components/src/ui/region-select.tsx b/packages/components/src/ui/region-select.tsx index aae3c57d..29d4ccf2 100644 --- a/packages/components/src/ui/region-select.tsx +++ b/packages/components/src/ui/region-select.tsx @@ -1,13 +1,12 @@ import * as React from 'react'; import { Check, ChevronDown } from 'lucide-react'; import { cn } from './utils'; +import { useOverlayTriggerState } from 'react-stately'; +import { Popover } from '@radix-ui/react-popover'; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from './select'; + PopoverContent, + PopoverTrigger, +} from './popover'; export interface RegionOption { label: string; @@ -35,39 +34,118 @@ export function RegionSelect({ contentClassName, itemClassName, }: RegionSelectProps) { + const popoverState = useOverlayTriggerState({}); + const [query, setQuery] = React.useState(''); + const triggerRef = React.useRef(null); + const popoverRef = React.useRef(null); + const [menuWidth, setMenuWidth] = React.useState(undefined); + + React.useEffect(() => { + if (triggerRef.current) setMenuWidth(triggerRef.current.offsetWidth); + }, []); + + const selectedOption = options.find((o) => o.value === value); + + const filtered = React.useMemo( + () => (query ? options.filter((o) => `${o.label}`.toLowerCase().includes(query.trim().toLowerCase())) : options), + [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]); + return ( - +
+
+ setQuery(e.target.value)} + placeholder="Search..." + // focus after mount for accessibility without using autoFocus + ref={(el) => { + if (el) queueMicrotask(() => el.focus()); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + const toSelect = enterCandidate; + if (toSelect) { + onValueChange?.(toSelect.value); + setQuery(''); + popoverState.close(); + triggerRef.current?.focus(); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + setQuery(''); + popoverState.close(); + triggerRef.current?.focus(); + } + }} + className="w-full h-9 rounded-md bg-white px-2 text-sm leading-none focus:ring-0 focus:outline-none border border-input" + /> +
+
    + {filtered.length === 0 &&
  • No results.
  • } + {filtered.map((option) => { + const isSelected = option.value === value; + const isEnterCandidate = enterCandidate?.value === option.value && !isSelected; + return ( +
  • + +
  • + ); + })} +
+
+ + ); } diff --git a/yarn.lock b/yarn.lock index 84384726..c99b3f11 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1178,6 +1178,33 @@ __metadata: languageName: node linkType: hard +"@internationalized/date@npm:^3.8.2": + version: 3.8.2 + resolution: "@internationalized/date@npm:3.8.2" + dependencies: + "@swc/helpers": "npm:^0.5.0" + checksum: 10c0/5e8dd13bf91e74109d9858752b6fdff533d510170bb0cdd8904e1d636ad31d56b6fe90805094a946b52d2035b0a3bb4a0608e3c287acd195a80b9ed041a9e4d5 + languageName: node + linkType: hard + +"@internationalized/number@npm:^3.6.4": + version: 3.6.4 + resolution: "@internationalized/number@npm:3.6.4" + dependencies: + "@swc/helpers": "npm:^0.5.0" + checksum: 10c0/40fe4719cba5886ef46e6f5da435decc68d932fe5021b1775e41f3bee08d60aafdee22be762c8eb26bc36716472a1532d54daab2982c7770af8b5f62d16f3577 + languageName: node + linkType: hard + +"@internationalized/string@npm:^3.2.7": + version: 3.2.7 + resolution: "@internationalized/string@npm:3.2.7" + dependencies: + "@swc/helpers": "npm:^0.5.0" + checksum: 10c0/8f7bea379ce047026ef20d535aa1bd7612a5e5a5108d1e514965696a46bce34e38111411943b688d00dae2c81eae7779ae18343961310696d32ebb463a19b94a + languageName: node + linkType: hard + "@isaacs/balanced-match@npm:^4.0.1": version: 4.0.1 resolution: "@isaacs/balanced-match@npm:4.0.1" @@ -2848,6 +2875,610 @@ __metadata: languageName: node linkType: hard +"@react-stately/calendar@npm:^3.8.3": + version: 3.8.3 + resolution: "@react-stately/calendar@npm:3.8.3" + dependencies: + "@internationalized/date": "npm:^3.8.2" + "@react-stately/utils": "npm:^3.10.8" + "@react-types/calendar": "npm:^3.7.3" + "@react-types/shared": "npm:^3.31.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/258691b4047381d0ae1c4fdd0eed519be63a731201630dab2b4907f14f35eeb5b9d426655125d66153de61ccf1d5673ac819d721e174b9e6cb9b15ce9ac71644 + languageName: node + linkType: hard + +"@react-stately/checkbox@npm:^3.7.0": + version: 3.7.0 + resolution: "@react-stately/checkbox@npm:3.7.0" + dependencies: + "@react-stately/form": "npm:^3.2.0" + "@react-stately/utils": "npm:^3.10.8" + "@react-types/checkbox": "npm:^3.10.0" + "@react-types/shared": "npm:^3.31.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/1d8646ecdd1bd527bec60075f46bf12ed8abb259eca653d1d95dd72f967659e06d5d536aedcf87554630d912e1b819ad66a61761b9ef8e284f63dd397a8e90ff + languageName: node + linkType: hard + +"@react-stately/collections@npm:^3.12.6": + version: 3.12.6 + resolution: "@react-stately/collections@npm:3.12.6" + dependencies: + "@react-types/shared": "npm:^3.31.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/4d200565c88ef179c15653eaaa2079b5e96fd8ed3ab34b863e7a4a7c421cf3f90f4348db37cfd5842bbdb17df165992c07a6013809aa550459819e057ca208c4 + languageName: node + linkType: hard + +"@react-stately/color@npm:^3.9.0": + version: 3.9.0 + resolution: "@react-stately/color@npm:3.9.0" + dependencies: + "@internationalized/number": "npm:^3.6.4" + "@internationalized/string": "npm:^3.2.7" + "@react-stately/form": "npm:^3.2.0" + "@react-stately/numberfield": "npm:^3.10.0" + "@react-stately/slider": "npm:^3.7.0" + "@react-stately/utils": "npm:^3.10.8" + "@react-types/color": "npm:^3.1.0" + "@react-types/shared": "npm:^3.31.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/673df3a14908b794a16c83152eab06876a2e32afa21ffbfe77b678b4d6a523ad43a80db1c6606c0d26e7c3f976520f4d07df97019b0921af38017c304a91ca57 + languageName: node + linkType: hard + +"@react-stately/combobox@npm:^3.11.0": + version: 3.11.0 + resolution: "@react-stately/combobox@npm:3.11.0" + dependencies: + "@react-stately/collections": "npm:^3.12.6" + "@react-stately/form": "npm:^3.2.0" + "@react-stately/list": "npm:^3.12.4" + "@react-stately/overlays": "npm:^3.6.18" + "@react-stately/select": "npm:^3.7.0" + "@react-stately/utils": "npm:^3.10.8" + "@react-types/combobox": "npm:^3.13.7" + "@react-types/shared": "npm:^3.31.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/a0b9e6b26f9faed807a5ff44259da7e6006e8e4f148c7a58c78081d30199a222cb6583c198092f8a3e43358c38f4c6e9992c74990b60a7391926e2a473687ebb + languageName: node + linkType: hard + +"@react-stately/data@npm:^3.13.2": + version: 3.13.2 + resolution: "@react-stately/data@npm:3.13.2" + dependencies: + "@react-types/shared": "npm:^3.31.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/f8d98099f0444b5c154fc9e6dc5f08fd50b05f1b558a82b36aaf955469914d03548dd5e6d465a314a31346d3853c3a7b07ffc426422fbb0700cf9ddbafcfe17d + languageName: node + linkType: hard + +"@react-stately/datepicker@npm:^3.15.0": + version: 3.15.0 + resolution: "@react-stately/datepicker@npm:3.15.0" + dependencies: + "@internationalized/date": "npm:^3.8.2" + "@internationalized/string": "npm:^3.2.7" + "@react-stately/form": "npm:^3.2.0" + "@react-stately/overlays": "npm:^3.6.18" + "@react-stately/utils": "npm:^3.10.8" + "@react-types/datepicker": "npm:^3.13.0" + "@react-types/shared": "npm:^3.31.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/11edb1c2fc0559c92a696b5edf24f83c798ac260e0c92cabe0d33e63098570d06622f274d4c430d867375df76c77993648d225e31e48c2b74820f772431868d5 + languageName: node + linkType: hard + +"@react-stately/disclosure@npm:^3.0.6": + version: 3.0.6 + resolution: "@react-stately/disclosure@npm:3.0.6" + dependencies: + "@react-stately/utils": "npm:^3.10.8" + "@react-types/shared": "npm:^3.31.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/f251d7d947528a5b4d0dda0d224d8f226bde89c42b26d72f404fa13d86ffe765874fc79a985d756f97404a02a35083efc503ed28c4a44c1d689c8231fe424841 + languageName: node + linkType: hard + +"@react-stately/dnd@npm:^3.6.1": + version: 3.6.1 + resolution: "@react-stately/dnd@npm:3.6.1" + dependencies: + "@react-stately/selection": "npm:^3.20.4" + "@react-types/shared": "npm:^3.31.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/52b46460f8484204520544bacfcd538f1d42477599ba5b4dcafd89a870f0c2c9690f74e6635ee82ba073053451d3b4887f4e0ffaee5908d5f97b4bcb77d591e9 + languageName: node + linkType: hard + +"@react-stately/flags@npm:^3.1.2": + version: 3.1.2 + resolution: "@react-stately/flags@npm:3.1.2" + dependencies: + "@swc/helpers": "npm:^0.5.0" + checksum: 10c0/d86890ce662f04c7d8984e9560527f46c9779b97757abded9e1bf7e230a6900a0ea7a3e7c22534de8d2ff278abae194e4e4ad962d710f3b04c52a4e1011c2e5b + languageName: node + linkType: hard + +"@react-stately/form@npm:^3.2.0": + version: 3.2.0 + resolution: "@react-stately/form@npm:3.2.0" + dependencies: + "@react-types/shared": "npm:^3.31.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/da9c2988540e6d97f203d554e7cd6d306a91a76b12ec9a3d18bb8f28afe90d3dafb5b6bab8bef719b4a6993dd9cbf1054c15c6cf4eb45576fc2694cb558aa7ce + languageName: node + linkType: hard + +"@react-stately/grid@npm:^3.11.4": + version: 3.11.4 + resolution: "@react-stately/grid@npm:3.11.4" + dependencies: + "@react-stately/collections": "npm:^3.12.6" + "@react-stately/selection": "npm:^3.20.4" + "@react-types/grid": "npm:^3.3.4" + "@react-types/shared": "npm:^3.31.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/229e064ee94f9384db226acbcbe5adb3438da79fc5824a16f99283e33eba5dc58518cacd58c10261432962c02f06ad0707a4b68ef822b5ba2eaa920ce70085ec + languageName: node + linkType: hard + +"@react-stately/list@npm:^3.12.4": + version: 3.12.4 + resolution: "@react-stately/list@npm:3.12.4" + dependencies: + "@react-stately/collections": "npm:^3.12.6" + "@react-stately/selection": "npm:^3.20.4" + "@react-stately/utils": "npm:^3.10.8" + "@react-types/shared": "npm:^3.31.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/46e09c1c75188502b9a47571584ec5000f31bfc4a82dca63e8bd97d909f080dd41bd39dc2aada181c17acf9d6b110af41c5a36db76a6a7ceb078f46343d4a21a + languageName: node + linkType: hard + +"@react-stately/menu@npm:^3.9.6": + version: 3.9.6 + resolution: "@react-stately/menu@npm:3.9.6" + dependencies: + "@react-stately/overlays": "npm:^3.6.18" + "@react-types/menu": "npm:^3.10.3" + "@react-types/shared": "npm:^3.31.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/5e78cfb9ff33f3dbfff69bc3f8991fd68493a8480c6b42c3f1b9988f78d49fd379cfcfc3d3e2549e7fb08d4fbf7c8bc6d5dcefe08a64d5062ffdb2de233bb86e + languageName: node + linkType: hard + +"@react-stately/numberfield@npm:^3.10.0": + version: 3.10.0 + resolution: "@react-stately/numberfield@npm:3.10.0" + dependencies: + "@internationalized/number": "npm:^3.6.4" + "@react-stately/form": "npm:^3.2.0" + "@react-stately/utils": "npm:^3.10.8" + "@react-types/numberfield": "npm:^3.8.13" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/0f08ff0ffaa2966b21e31619e5e48993b9662e633d08def0517ac511ed0e6655b77e45f8f98e7c793ab23e9d212b54e0d23620065dc53cb1432f5f6ec7880e6b + languageName: node + linkType: hard + +"@react-stately/overlays@npm:^3.6.18": + version: 3.6.18 + resolution: "@react-stately/overlays@npm:3.6.18" + dependencies: + "@react-stately/utils": "npm:^3.10.8" + "@react-types/overlays": "npm:^3.9.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/3dff0037459f6e4d5dd9b2bd754c02985a9aa9c9b0ebd16451ea607f9c0b1a194e51fbdbac4d3b35c3350a425515d39e5b84cbe650102fee82afb00516b27ad0 + languageName: node + linkType: hard + +"@react-stately/radio@npm:^3.11.0": + version: 3.11.0 + resolution: "@react-stately/radio@npm:3.11.0" + dependencies: + "@react-stately/form": "npm:^3.2.0" + "@react-stately/utils": "npm:^3.10.8" + "@react-types/radio": "npm:^3.9.0" + "@react-types/shared": "npm:^3.31.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/77c18797de4c4025f84071daa809c6f287b71bf53448bf4c2be041d801685143aef58f130ecc43f785d7d3ee51776eeefefb776b33449fe91c0f628c9c41afe8 + languageName: node + linkType: hard + +"@react-stately/searchfield@npm:^3.5.14": + version: 3.5.14 + resolution: "@react-stately/searchfield@npm:3.5.14" + dependencies: + "@react-stately/utils": "npm:^3.10.8" + "@react-types/searchfield": "npm:^3.6.4" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/83c48686d0c2b9eee3604839c3d910504aebd51fbed5a0b6bd23f959f70c01d1d2bc66e96c2d86ab853dbee831edf905ad33765a0c9aea081de94fed21fec534 + languageName: node + linkType: hard + +"@react-stately/select@npm:^3.7.0": + version: 3.7.0 + resolution: "@react-stately/select@npm:3.7.0" + dependencies: + "@react-stately/form": "npm:^3.2.0" + "@react-stately/list": "npm:^3.12.4" + "@react-stately/overlays": "npm:^3.6.18" + "@react-types/select": "npm:^3.10.0" + "@react-types/shared": "npm:^3.31.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/8d60298a9762efe5dbccfbabe59e3c5a24c66cba7d221b505936d6e027a64869809fe98685c8d57ce77a671c550a7a49c03b87dffde2f25a34a4f8c58aa66e19 + languageName: node + linkType: hard + +"@react-stately/selection@npm:^3.20.4": + version: 3.20.4 + resolution: "@react-stately/selection@npm:3.20.4" + dependencies: + "@react-stately/collections": "npm:^3.12.6" + "@react-stately/utils": "npm:^3.10.8" + "@react-types/shared": "npm:^3.31.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/16b03724c253a965c7572203bc0f59fbb41ea54bf4dda029b93ea3bd9330e43411e21f9fe5305ddf9f55586e1df014da030b87c7569cee39919a37b86b5f26ae + languageName: node + linkType: hard + +"@react-stately/slider@npm:^3.7.0": + version: 3.7.0 + resolution: "@react-stately/slider@npm:3.7.0" + dependencies: + "@react-stately/utils": "npm:^3.10.8" + "@react-types/shared": "npm:^3.31.0" + "@react-types/slider": "npm:^3.8.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/ed7c0977dad9b2fa09f0b6804aab9f3b6227c0f9a47faf9f51158be718970653734153bf9c636f8bf698a7d54980f63a3412f1e7778e4b0d9510df7dd3304a26 + languageName: node + linkType: hard + +"@react-stately/table@npm:^3.14.4": + version: 3.14.4 + resolution: "@react-stately/table@npm:3.14.4" + dependencies: + "@react-stately/collections": "npm:^3.12.6" + "@react-stately/flags": "npm:^3.1.2" + "@react-stately/grid": "npm:^3.11.4" + "@react-stately/selection": "npm:^3.20.4" + "@react-stately/utils": "npm:^3.10.8" + "@react-types/grid": "npm:^3.3.4" + "@react-types/shared": "npm:^3.31.0" + "@react-types/table": "npm:^3.13.2" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/85c4a8745f6de9dc13540ed7d61ad8ab812a386ebe5f6684f014ef8b78ab2e446dd4fe8154f224b9621e291a9844ed76bf79d13cc6cff2712ab2458c7dfea9d8 + languageName: node + linkType: hard + +"@react-stately/tabs@npm:^3.8.4": + version: 3.8.4 + resolution: "@react-stately/tabs@npm:3.8.4" + dependencies: + "@react-stately/list": "npm:^3.12.4" + "@react-types/shared": "npm:^3.31.0" + "@react-types/tabs": "npm:^3.3.17" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/d5c5826c126dd247aaa9cbcd556886fbaa328f2005f393dc93421b0f2f6d4fdaa032b196fb8f6033d3af369b66d30a7d885c8d66d4154d5cbb46a9e5d445caf4 + languageName: node + linkType: hard + +"@react-stately/toast@npm:^3.1.2": + version: 3.1.2 + resolution: "@react-stately/toast@npm:3.1.2" + dependencies: + "@swc/helpers": "npm:^0.5.0" + use-sync-external-store: "npm:^1.4.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/5de06a2ca5830824a236f809e44a5084ae58a4f463c86aa2e72ec84c8ca632dfe1f5054248a9a1f6ee2aa213e22bfc186e0f4d5ef9a552eb369ee906686f8fec + languageName: node + linkType: hard + +"@react-stately/toggle@npm:^3.9.0": + version: 3.9.0 + resolution: "@react-stately/toggle@npm:3.9.0" + dependencies: + "@react-stately/utils": "npm:^3.10.8" + "@react-types/checkbox": "npm:^3.10.0" + "@react-types/shared": "npm:^3.31.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/eab0003f9709d8140aa8fa7a0283b186de7fa45511739d163fc23398517d4b094679545a136f940f7ba16344481f84f5829a03939075b7400fc428c7534b9473 + languageName: node + linkType: hard + +"@react-stately/tooltip@npm:^3.5.6": + version: 3.5.6 + resolution: "@react-stately/tooltip@npm:3.5.6" + dependencies: + "@react-stately/overlays": "npm:^3.6.18" + "@react-types/tooltip": "npm:^3.4.19" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/3c528262be714a489e8bdafd4b2a17087b79108028b2f7d994654966fb9b473dc922481320b84ae9825dac09a9620678cc18bdf447fd741dabcf846f0e333dd8 + languageName: node + linkType: hard + +"@react-stately/tree@npm:^3.9.1": + version: 3.9.1 + resolution: "@react-stately/tree@npm:3.9.1" + dependencies: + "@react-stately/collections": "npm:^3.12.6" + "@react-stately/selection": "npm:^3.20.4" + "@react-stately/utils": "npm:^3.10.8" + "@react-types/shared": "npm:^3.31.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/bf72a186e8995c5976c9c3be5f7e8f6f7353c3c2ebcbe54ce8841891f2b00815827537d5e2459dd37d7677de2e699f4147ed5e4641e74d2d758411fef77240df + languageName: node + linkType: hard + +"@react-stately/utils@npm:^3.10.8": + version: 3.10.8 + resolution: "@react-stately/utils@npm:3.10.8" + dependencies: + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/a97cc292986e3eeb2ceb1626671ce60e8342a3ff35ab92bcfcb94bd6b28729836cc592e3fe4df2fba603e5fdd26291be77b7f60441920298c282bb93f424feba + languageName: node + linkType: hard + +"@react-types/calendar@npm:^3.7.3": + version: 3.7.3 + resolution: "@react-types/calendar@npm:3.7.3" + dependencies: + "@internationalized/date": "npm:^3.8.2" + "@react-types/shared": "npm:^3.31.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/5f2ca0984ea83dfcccdce8338e8f56e6f45bed9f94440e81c1ad521a1e8eb6b4c41b5b246ad52b2d447b71419bdd812a8b16b49290f2df05a354b0581f1ec604 + languageName: node + linkType: hard + +"@react-types/checkbox@npm:^3.10.0": + version: 3.10.0 + resolution: "@react-types/checkbox@npm:3.10.0" + dependencies: + "@react-types/shared": "npm:^3.31.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/7e7fa09b955a618f8b327c5859f9c7481552c40229bd0eed9596cc4825125eda45e4f59891c12c48a6615b53d6e7504a8c112ee2a3820b6511c8afa013387600 + languageName: node + linkType: hard + +"@react-types/color@npm:^3.1.0": + version: 3.1.0 + resolution: "@react-types/color@npm:3.1.0" + dependencies: + "@react-types/shared": "npm:^3.31.0" + "@react-types/slider": "npm:^3.8.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/f5ac75bfdba17cab37bb9a50a7b2d04500ed2902bbfd4299923a6d88f9d7b78cc0e2b93336ded8dd1bf9a2612074e7627b61a7eb0ff6cb0d46d6ebd5e38f914e + languageName: node + linkType: hard + +"@react-types/combobox@npm:^3.13.7": + version: 3.13.7 + resolution: "@react-types/combobox@npm:3.13.7" + dependencies: + "@react-types/shared": "npm:^3.31.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/7e8b9d74c09b225230ac1fb46458eb29c60c54b86f14802642f9497ecf26ff86404d0a173a2d471783fde1a6214709c57b44b6c7a381ac5d2e67df145c26f9b4 + languageName: node + linkType: hard + +"@react-types/datepicker@npm:^3.13.0": + version: 3.13.0 + resolution: "@react-types/datepicker@npm:3.13.0" + dependencies: + "@internationalized/date": "npm:^3.8.2" + "@react-types/calendar": "npm:^3.7.3" + "@react-types/overlays": "npm:^3.9.0" + "@react-types/shared": "npm:^3.31.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/eb4e97f145f0a7b41b43b58afaa0cb98bbdfd1d0b2c805bdde8e2fed74ad36869de900041cc2228f6a618051f684bb7222fc85939c6d622e2c12519fd639e0a1 + languageName: node + linkType: hard + +"@react-types/grid@npm:^3.3.4": + version: 3.3.4 + resolution: "@react-types/grid@npm:3.3.4" + dependencies: + "@react-types/shared": "npm:^3.31.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/c3203832fda6503eef159a5334b8029860bede0c298acf23689bc13a3373c1ffefce82a4a0f7e8f723a8b18ae664bf341ae51821f82bfa3e61fbc97306b52838 + languageName: node + linkType: hard + +"@react-types/menu@npm:^3.10.3": + version: 3.10.3 + resolution: "@react-types/menu@npm:3.10.3" + dependencies: + "@react-types/overlays": "npm:^3.9.0" + "@react-types/shared": "npm:^3.31.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/a978baa54c13e13125010dcd3613e19fcd020e0abda74ffa222b79a6ba09c8c18acb25f2b3f4fe76da02e94460197b12b925aa39aeb00e8f88560742b73e47d4 + languageName: node + linkType: hard + +"@react-types/numberfield@npm:^3.8.13": + version: 3.8.13 + resolution: "@react-types/numberfield@npm:3.8.13" + dependencies: + "@react-types/shared": "npm:^3.31.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/12457464bce4e64628bf07f2e09e5518e64a740ec04d9c4012a478892d37e937e7ffe920d36cafc60fbd03e9d777c627f3c5ed69fafcacb244a1275caf27e979 + languageName: node + linkType: hard + +"@react-types/overlays@npm:^3.9.0": + version: 3.9.0 + resolution: "@react-types/overlays@npm:3.9.0" + dependencies: + "@react-types/shared": "npm:^3.31.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/daf8e878a96181a48b22f1ed3f48d769c72290c858becfbca6adf8da255531b4f79124d184c4fbd3534cfaa6a42edeb221f9b54cfa75132e131e781c3108a2c1 + languageName: node + linkType: hard + +"@react-types/radio@npm:^3.9.0": + version: 3.9.0 + resolution: "@react-types/radio@npm:3.9.0" + dependencies: + "@react-types/shared": "npm:^3.31.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/4669809d7d6d7cc91b82c6ff2b41e61773a9360c171de307b4e67a627358c99d8a5d53f34a40c7478733d5488d151fd2d62230d84f195fcbf4bb18ba9503ec9c + languageName: node + linkType: hard + +"@react-types/searchfield@npm:^3.6.4": + version: 3.6.4 + resolution: "@react-types/searchfield@npm:3.6.4" + dependencies: + "@react-types/shared": "npm:^3.31.0" + "@react-types/textfield": "npm:^3.12.4" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/4449962a75e991185e836a7dc91f6e13118a60d281c2b9ed950da988d8024f0672aed4a41977a875253c9cda1cab2d77110976ee7b6a51cf70642621a5df42a1 + languageName: node + linkType: hard + +"@react-types/select@npm:^3.10.0": + version: 3.10.0 + resolution: "@react-types/select@npm:3.10.0" + dependencies: + "@react-types/shared": "npm:^3.31.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/70f9afee1af0e718c06c10f84c41d2cc0868684d569479768c8aa9c4d42f83d8e50b04745e1bbae4a62eb860019605355cb1c1419ef0ea2cbce0494d3f20a6d6 + languageName: node + linkType: hard + +"@react-types/shared@npm:^3.31.0": + version: 3.31.0 + resolution: "@react-types/shared@npm:3.31.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/6944eba44a5bc390a0c4136f9bdcc8caee8408bba2d1b90160ae7397b9455efb3f28864a796c15e26132b522a60c389a7f0cf67674d64aec2947601962d3e4d6 + languageName: node + linkType: hard + +"@react-types/slider@npm:^3.8.0": + version: 3.8.0 + resolution: "@react-types/slider@npm:3.8.0" + dependencies: + "@react-types/shared": "npm:^3.31.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/461925132d56cf8bae8d29f7395d2fee23111d4319dc8daaeccdcfa5af623f2f9936c0c64942c4de6b295923e96e3d68bc04af602e8a95e43718faaa13ef5330 + languageName: node + linkType: hard + +"@react-types/table@npm:^3.13.2": + version: 3.13.2 + resolution: "@react-types/table@npm:3.13.2" + dependencies: + "@react-types/grid": "npm:^3.3.4" + "@react-types/shared": "npm:^3.31.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/42e37782d1d914472ee049dd697041f97bec5b6f9481c8c91775e2d7bae24ce1ca10d97e0413c133834241ddb7fdc0c9ae453fd06aa2f42c4dea76d14a27090f + languageName: node + linkType: hard + +"@react-types/tabs@npm:^3.3.17": + version: 3.3.17 + resolution: "@react-types/tabs@npm:3.3.17" + dependencies: + "@react-types/shared": "npm:^3.31.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/803213aac124419dc6534c805fc4627af7cedfee70127ffeb4684e3cdfb5ff4e4be955f776682d9cac5f31c5118705906a555724790eef7a33079103b116f5c0 + languageName: node + linkType: hard + +"@react-types/textfield@npm:^3.12.4": + version: 3.12.4 + resolution: "@react-types/textfield@npm:3.12.4" + dependencies: + "@react-types/shared": "npm:^3.31.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/5a70c1e8613d0d6e80c72b54ba89472be77b8b389063d128917183d1e95b4e81cbf005c169c4e431c8bfd3efed75e2aa41ce733006ef63e7e29a53f246b04663 + languageName: node + linkType: hard + +"@react-types/tooltip@npm:^3.4.19": + version: 3.4.19 + resolution: "@react-types/tooltip@npm:3.4.19" + dependencies: + "@react-types/overlays": "npm:^3.9.0" + "@react-types/shared": "npm:^3.31.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/ea51a1870efca8fbc80df3d25a2afae04ad5800437eca275ac3f061f0ce62db1b4c16ed4abe4c661eec792a4c874399f804693b3ca8cb2db1b4e98133f2ad9d4 + languageName: node + linkType: hard + "@remix-run/react@npm:^2.17.0": version: 2.17.0 resolution: "@remix-run/react@npm:2.17.0" @@ -3498,6 +4129,15 @@ __metadata: languageName: node linkType: hard +"@swc/helpers@npm:^0.5.0": + version: 0.5.17 + resolution: "@swc/helpers@npm:0.5.17" + dependencies: + tslib: "npm:^2.8.0" + checksum: 10c0/fe1f33ebb968558c5a0c595e54f2e479e4609bff844f9ca9a2d1ffd8dd8504c26f862a11b031f48f75c95b0381c2966c3dd156e25942f90089badd24341e7dbb + languageName: node + linkType: hard + "@swc/jest@npm:^0.2.23": version: 0.2.39 resolution: "@swc/jest@npm:0.2.39" @@ -6351,6 +6991,7 @@ __metadata: react-dom: "npm:^19.1.1" react-phone-number-input: "npm:^3.4.12" react-router-dom: "npm:^7.6.2" + react-stately: "npm:^3.40.0" turbo: "npm:^2.3.3" languageName: unknown linkType: soft @@ -9657,6 +10298,42 @@ __metadata: languageName: node linkType: hard +"react-stately@npm:^3.40.0": + version: 3.40.0 + resolution: "react-stately@npm:3.40.0" + dependencies: + "@react-stately/calendar": "npm:^3.8.3" + "@react-stately/checkbox": "npm:^3.7.0" + "@react-stately/collections": "npm:^3.12.6" + "@react-stately/color": "npm:^3.9.0" + "@react-stately/combobox": "npm:^3.11.0" + "@react-stately/data": "npm:^3.13.2" + "@react-stately/datepicker": "npm:^3.15.0" + "@react-stately/disclosure": "npm:^3.0.6" + "@react-stately/dnd": "npm:^3.6.1" + "@react-stately/form": "npm:^3.2.0" + "@react-stately/list": "npm:^3.12.4" + "@react-stately/menu": "npm:^3.9.6" + "@react-stately/numberfield": "npm:^3.10.0" + "@react-stately/overlays": "npm:^3.6.18" + "@react-stately/radio": "npm:^3.11.0" + "@react-stately/searchfield": "npm:^3.5.14" + "@react-stately/select": "npm:^3.7.0" + "@react-stately/selection": "npm:^3.20.4" + "@react-stately/slider": "npm:^3.7.0" + "@react-stately/table": "npm:^3.14.4" + "@react-stately/tabs": "npm:^3.8.4" + "@react-stately/toast": "npm:^3.1.2" + "@react-stately/toggle": "npm:^3.9.0" + "@react-stately/tooltip": "npm:^3.5.6" + "@react-stately/tree": "npm:^3.9.1" + "@react-types/shared": "npm:^3.31.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/cf5a483e5ce3cc2af158832b65b04e98b2f70603d111c9a2f018270fcaaf9104399c337dc1b604d5816bee9f1a54bc59b72e9835ac990efecde275c35c79127a + languageName: node + linkType: hard + "react-style-singleton@npm:^2.2.2, react-style-singleton@npm:^2.2.3": version: 2.2.3 resolution: "react-style-singleton@npm:2.2.3" @@ -11073,7 +11750,7 @@ __metadata: languageName: node linkType: hard -"use-sync-external-store@npm:^1.5.0": +"use-sync-external-store@npm:^1.4.0, use-sync-external-store@npm:^1.5.0": version: 1.5.0 resolution: "use-sync-external-store@npm:1.5.0" peerDependencies: From 7bf146e8aafda06c3ab0980bbdf2b8ad8fe9e297 Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Sat, 16 Aug 2025 05:14:24 +0000 Subject: [PATCH 08/24] Fix region-select styling issues: - Lighten selected gray background - Darken hover gray background - Remove black border around dropdown - Make search input full width without border Co-authored-by: Jake Ruesink --- packages/components/src/ui/region-select.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/components/src/ui/region-select.tsx b/packages/components/src/ui/region-select.tsx index 29d4ccf2..f32d0f51 100644 --- a/packages/components/src/ui/region-select.tsx +++ b/packages/components/src/ui/region-select.tsx @@ -76,12 +76,12 @@ export function RegionSelect({ -
+
    @@ -128,8 +128,8 @@ export function RegionSelect({ }} className={cn( '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 hover:bg-gray-50', - isSelected && 'bg-gray-200', + 'text-gray-900 hover:bg-gray-100', + isSelected && 'bg-gray-100', !isSelected && isEnterCandidate && 'bg-gray-50', itemClassName )} From 145293cf865ad612320b0195c3aecbcb6028be19 Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Sat, 16 Aug 2025 05:24:35 +0000 Subject: [PATCH 09/24] Fix region-select component issues: - Fix Alabama gray background issue by removing default background - Add auto-scroll to selected item when dropdown opens - Improve hover and selected state styling Co-authored-by: Jake Ruesink --- packages/components/src/ui/region-select.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/components/src/ui/region-select.tsx b/packages/components/src/ui/region-select.tsx index f32d0f51..12d363e7 100644 --- a/packages/components/src/ui/region-select.tsx +++ b/packages/components/src/ui/region-select.tsx @@ -38,12 +38,23 @@ export function RegionSelect({ const [query, setQuery] = React.useState(''); const triggerRef = React.useRef(null); const popoverRef = React.useRef(null); + const selectedItemRef = React.useRef(null); const [menuWidth, setMenuWidth] = React.useState(undefined); React.useEffect(() => { if (triggerRef.current) setMenuWidth(triggerRef.current.offsetWidth); }, []); + // Scroll to selected item when dropdown opens + React.useEffect(() => { + if (popoverState.isOpen && selectedItemRef.current) { + // Use setTimeout to ensure the DOM is fully rendered + setTimeout(() => { + selectedItemRef.current?.scrollIntoView({ block: 'nearest' }); + }, 0); + } + }, [popoverState.isOpen]); + const selectedOption = options.find((o) => o.value === value); const filtered = React.useMemo( @@ -121,6 +132,7 @@ export function RegionSelect({
@@ -95,9 +96,9 @@ const handleFormSubmission = async (request: Request) => { }; }; -const meta: Meta = { - title: 'RemixHookForm/RegionSelect', - component: RegionSelect, +const meta: Meta = { + title: 'RemixHookForm/Select', + component: Select, parameters: { layout: 'centered' }, tags: ['autodocs'], decorators: [ @@ -111,7 +112,7 @@ const meta: Meta = { ], }), ], -} satisfies Meta; +} satisfies Meta; export default meta; type Story = StoryObj; @@ -120,7 +121,7 @@ export const Default: Story = { parameters: { docs: { description: { - story: 'A region select component for selecting US states, Canadian provinces, or custom regions.', + story: 'A select component for selecting options from a dropdown list. Includes specialized components for US states and Canadian provinces.', }, source: { code: ` @@ -160,7 +161,7 @@ const RegionSelectExample = () => { description="Select a Canadian province" /> - { ...US_STATES.slice(0, 5), ...CANADA_PROVINCES.slice(0, 5), ]} + placeholder="Select a custom region" />
@@ -303,4 +305,3 @@ export const FormSubmission: Story = { }); }, }; - diff --git a/apps/docs/src/remix-hook-form/region-select.test.tsx b/apps/docs/src/remix-hook-form/select.test.tsx similarity index 100% rename from apps/docs/src/remix-hook-form/region-select.test.tsx rename to apps/docs/src/remix-hook-form/select.test.tsx 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 9561cc4d..fb697b29 100644 --- a/packages/components/src/remix-hook-form/canada-province-select.tsx +++ b/packages/components/src/remix-hook-form/canada-province-select.tsx @@ -1,12 +1,12 @@ import * as React from 'react'; -import { RegionSelect, type RegionSelectProps } from './region-select'; +import { Select, type SelectProps } from './select'; import { CANADA_PROVINCES } from '../ui/data/canada-provinces'; -export type CanadaProvinceSelectProps = Omit; +export type CanadaProvinceSelectProps = Omit; export function CanadaProvinceSelect(props: CanadaProvinceSelectProps) { return ( - { + name: string; + label?: string; + description?: string; + className?: string; +} + +export function Select({ + name, + label, + description, + className, + ...props +}: SelectProps) { + const { control } = useRemixFormContext(); + + return ( + ( + + {label && {label}} + + + + {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 efcef37f..332252b0 100644 --- a/packages/components/src/remix-hook-form/us-state-select.tsx +++ b/packages/components/src/remix-hook-form/us-state-select.tsx @@ -1,12 +1,12 @@ import * as React from 'react'; -import { RegionSelect, type RegionSelectProps } from './region-select'; +import { Select, type SelectProps } from './select'; import { US_STATES } from '../ui/data/us-states'; -export type USStateSelectProps = Omit; +export type USStateSelectProps = Omit; export function USStateSelect(props: USStateSelectProps) { return ( - ; +export type CanadaProvinceSelectProps = Omit; export function CanadaProvinceSelect(props: CanadaProvinceSelectProps) { return ( - void; + placeholder?: string; + disabled?: boolean; + className?: string; + contentClassName?: string; + itemClassName?: string; +} -const Select = SelectPrimitive.Root; +export function Select({ + options, + value, + onValueChange, + placeholder = 'Select an option', + disabled = false, + className, + contentClassName, + itemClassName, +}: SelectProps) { + const popoverState = useOverlayTriggerState({}); + const [query, setQuery] = React.useState(''); + const triggerRef = React.useRef(null); + const popoverRef = React.useRef(null); + const selectedItemRef = React.useRef(null); + const [menuWidth, setMenuWidth] = React.useState(undefined); -const SelectGroup = SelectPrimitive.Group; + React.useEffect(() => { + if (triggerRef.current) setMenuWidth(triggerRef.current.offsetWidth); + }, []); -const SelectValue = SelectPrimitive.Value; + // Scroll to selected item when dropdown opens + React.useEffect(() => { + if (popoverState.isOpen && selectedItemRef.current) { + // Use setTimeout to ensure the DOM is fully rendered + setTimeout(() => { + selectedItemRef.current?.scrollIntoView({ block: 'nearest' }); + }, 0); + } + }, [popoverState.isOpen]); -const SelectTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - span]:line-clamp-1', - className - )} - {...props} - > - {children} - - - - -)); -SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + const selectedOption = options.find((o) => o.value === value); -const SelectScrollUpButton = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - -)); -SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + const filtered = React.useMemo( + () => (query ? options.filter((o) => `${o.label}`.toLowerCase().includes(query.trim().toLowerCase())) : options), + [options, query] + ); -const SelectScrollDownButton = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - -)); -SelectScrollDownButton.displayName = - SelectPrimitive.ScrollDownButton.displayName; + // 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]); -const SelectContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, position = 'popper', ...props }, ref) => ( - - - - + - {children} - - - - -)); -SelectContent.displayName = SelectPrimitive.Content.displayName; - -const SelectLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -SelectLabel.displayName = SelectPrimitive.Label.displayName; - -const SelectItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - - - - - {children} - -)); -SelectItem.displayName = SelectPrimitive.Item.displayName; - -const SelectSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -SelectSeparator.displayName = SelectPrimitive.Separator.displayName; - -export { - Select, - SelectGroup, - SelectValue, - SelectTrigger, - SelectContent, - SelectLabel, - SelectItem, - SelectSeparator, - SelectScrollUpButton, - SelectScrollDownButton, -}; - -export interface SelectOption { - label: string; - value: string; + {selectedOption?.label || placeholder} + + + +
+
+ setQuery(e.target.value)} + placeholder="Search..." + // focus after mount for accessibility without using autoFocus + ref={(el) => { + if (el) queueMicrotask(() => el.focus()); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + const toSelect = enterCandidate; + if (toSelect) { + onValueChange?.(toSelect.value); + setQuery(''); + popoverState.close(); + triggerRef.current?.focus(); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + setQuery(''); + popoverState.close(); + triggerRef.current?.focus(); + } + }} + 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) => { + const isSelected = option.value === value; + const isEnterCandidate = query.trim() !== '' && enterCandidate?.value === option.value && !isSelected; + return ( +
  • + +
  • + ); + })} +
+
+
+ + ); } diff --git a/packages/components/src/ui/us-state-select.tsx b/packages/components/src/ui/us-state-select.tsx index e2d42743..1572b949 100644 --- a/packages/components/src/ui/us-state-select.tsx +++ b/packages/components/src/ui/us-state-select.tsx @@ -1,12 +1,12 @@ import * as React from 'react'; -import { RegionSelect, type RegionSelectProps } from './region-select'; +import { Select, type SelectProps } from './select'; import { US_STATES } from './data/us-states'; -export type USStateSelectProps = Omit; +export type USStateSelectProps = Omit; export function USStateSelect(props: USStateSelectProps) { return ( - Date: Mon, 18 Aug 2025 20:07:54 +0000 Subject: [PATCH 13/24] Fix: Remove reference to non-existent toggle component --- packages/components/src/ui/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/components/src/ui/index.ts b/packages/components/src/ui/index.ts index 9cd33db2..1cb206d2 100644 --- a/packages/components/src/ui/index.ts +++ b/packages/components/src/ui/index.ts @@ -30,7 +30,6 @@ export * from './badge'; export * from './command'; export * from './select'; export * from './separator'; -export * from './toggle'; export * from './tooltip'; export * from './us-state-select'; export * from './canada-province-select'; From 520a2c7f4d87d6388fda3148b77a16400cd679ed Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Mon, 18 Aug 2025 20:17:23 +0000 Subject: [PATCH 14/24] Fix build errors in select components refactoring - Updated imports in data files to use SelectOption from select.tsx instead of RegionOption from region-select.tsx - Removed non-existent tooltip export from UI index file --- packages/components/src/ui/data/canada-provinces.ts | 5 ++--- packages/components/src/ui/data/us-states.ts | 5 ++--- packages/components/src/ui/index.ts | 2 -- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/components/src/ui/data/canada-provinces.ts b/packages/components/src/ui/data/canada-provinces.ts index 4be03a08..5f228dad 100644 --- a/packages/components/src/ui/data/canada-provinces.ts +++ b/packages/components/src/ui/data/canada-provinces.ts @@ -1,6 +1,6 @@ -import { RegionOption } from '../region-select'; +import { SelectOption } from '../select'; -export const CANADA_PROVINCES: RegionOption[] = [ +export const CANADA_PROVINCES: SelectOption[] = [ { value: 'AB', label: 'Alberta' }, { value: 'BC', label: 'British Columbia' }, { value: 'MB', label: 'Manitoba' }, @@ -15,4 +15,3 @@ export const CANADA_PROVINCES: RegionOption[] = [ { value: 'SK', label: 'Saskatchewan' }, { value: 'YT', label: 'Yukon' }, ]; - diff --git a/packages/components/src/ui/data/us-states.ts b/packages/components/src/ui/data/us-states.ts index 38b44ee3..bc4da2d0 100644 --- a/packages/components/src/ui/data/us-states.ts +++ b/packages/components/src/ui/data/us-states.ts @@ -1,6 +1,6 @@ -import { RegionOption } from '../region-select'; +import { SelectOption } from '../select'; -export const US_STATES: RegionOption[] = [ +export const US_STATES: SelectOption[] = [ { value: 'AL', label: 'Alabama' }, { value: 'AK', label: 'Alaska' }, { value: 'AZ', label: 'Arizona' }, @@ -53,4 +53,3 @@ export const US_STATES: RegionOption[] = [ { value: 'WY', label: 'Wyoming' }, { value: 'DC', label: 'District of Columbia' }, ]; - diff --git a/packages/components/src/ui/index.ts b/packages/components/src/ui/index.ts index 1cb206d2..e388b46f 100644 --- a/packages/components/src/ui/index.ts +++ b/packages/components/src/ui/index.ts @@ -30,9 +30,7 @@ export * from './badge'; export * from './command'; export * from './select'; export * from './separator'; -export * from './tooltip'; export * from './us-state-select'; export * from './canada-province-select'; export * from './data/us-states'; export * from './data/canada-provinces'; - From 9712c4feda21fc894b28fd2896987e731c8615f3 Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Mon, 18 Aug 2025 20:46:23 +0000 Subject: [PATCH 15/24] Remove dropdown menu select component - Removed dropdown-menu-select-field.tsx from UI components - Removed dropdown-menu-select.tsx from remix-hook-form components - Removed dropdown-menu-select.stories.tsx from docs - Updated index files to remove exports for these components This completes the migration to the new Select component. --- .../dropdown-menu-select.stories.tsx | 140 ---------------- .../remix-hook-form/dropdown-menu-select.tsx | 13 -- .../src/ui/dropdown-menu-select-field.tsx | 155 ------------------ 3 files changed, 308 deletions(-) delete mode 100644 apps/docs/src/remix-hook-form/dropdown-menu-select.stories.tsx delete mode 100644 packages/components/src/remix-hook-form/dropdown-menu-select.tsx delete mode 100644 packages/components/src/ui/dropdown-menu-select-field.tsx diff --git a/apps/docs/src/remix-hook-form/dropdown-menu-select.stories.tsx b/apps/docs/src/remix-hook-form/dropdown-menu-select.stories.tsx deleted file mode 100644 index e9df0d3e..00000000 --- a/apps/docs/src/remix-hook-form/dropdown-menu-select.stories.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { zodResolver } from '@hookform/resolvers/zod'; -import { DropdownMenuSelect } from '@lambdacurry/forms/remix-hook-form/dropdown-menu-select'; -import { Button } from '@lambdacurry/forms/ui/button'; -import { DropdownMenuSelectItem } from '@lambdacurry/forms/ui/dropdown-menu-select-field'; -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, screen, userEvent, within } from '@storybook/test'; -import { type ActionFunctionArgs, Form, useFetcher } from 'react-router'; -import { RemixFormProvider, createFormData, getValidatedFormData, useRemixForm } from 'remix-hook-form'; -import { z } from 'zod'; -import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub'; - -const AVAILABLE_FRUITS = [ - { value: 'apple', label: 'Apple' }, - { value: 'banana', label: 'Banana' }, - { value: 'orange', label: 'Orange' }, - { value: 'grape', label: 'Grape' }, - { value: 'strawberry', label: 'Strawberry' }, -] as const; - -const formSchema = z.object({ - fruit: z.string({ - required_error: 'Please select a fruit', - }), -}); - -type FormData = z.infer; - -const ControlledDropdownMenuSelectExample = () => { - const fetcher = useFetcher<{ message: string; selectedFruit: string }>(); - const methods = useRemixForm({ - resolver: zodResolver(formSchema), - defaultValues: { - fruit: '', - }, - fetcher, - submitConfig: { - action: '/', - method: 'post', - }, - submitHandlers: { - onValid: (data) => { - fetcher.submit( - createFormData({ - fruit: data.fruit, - }), - { - method: 'post', - action: '/', - }, - ); - }, - }, - }); - - return ( - -
-
- - {AVAILABLE_FRUITS.map((fruit) => ( - - {fruit.label} - - ))} - - - {fetcher.data?.selectedFruit && ( -
-

Submitted with fruit:

-

- {AVAILABLE_FRUITS.find((fruit) => fruit.value === fetcher.data?.selectedFruit)?.label} -

-
- )} -
-
-
- ); -}; - -const handleFormSubmission = async (request: Request) => { - const { data, errors } = await getValidatedFormData(request, zodResolver(formSchema)); - - if (errors) { - return { errors }; - } - - return { message: 'Fruit selected successfully', selectedFruit: data.fruit }; -}; - -const meta: Meta = { - title: 'RemixHookForm/DropdownMenuSelect', - component: DropdownMenuSelect, - parameters: { layout: 'centered' }, - tags: ['autodocs'], - decorators: [ - withReactRouterStubDecorator({ - routes: [ - { - path: '/', - Component: ControlledDropdownMenuSelectExample, - action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request), - }, - ], - }), - ], -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - parameters: { - docs: { - description: { - story: 'A dropdown menu select component for selecting a single option.', - }, - }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Open the dropdown - const dropdownButton = canvas.getByRole('button', { name: 'Select an option' }); - await userEvent.click(dropdownButton); - - // Select an option (portal renders outside the canvas) - const option = screen.getByRole('menuitem', { name: 'Banana' }); - await userEvent.click(option); - - // Submit the form - const submitButton = canvas.getByRole('button', { name: 'Submit' }); - await userEvent.click(submitButton); - - // Check if the selected option is displayed - await expect(await canvas.findByText('Banana')).toBeInTheDocument(); - }, -}; diff --git a/packages/components/src/remix-hook-form/dropdown-menu-select.tsx b/packages/components/src/remix-hook-form/dropdown-menu-select.tsx deleted file mode 100644 index 0969ebe6..00000000 --- a/packages/components/src/remix-hook-form/dropdown-menu-select.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { useRemixFormContext } from 'remix-hook-form'; -import { - DropdownMenuSelectField as BaseDropdownMenuSelectField, - type DropdownMenuSelectProps as BaseDropdownMenuSelectProps, -} from '../ui/dropdown-menu-select-field'; - -export type DropdownMenuSelectProps = Omit; - -export function DropdownMenuSelect(props: DropdownMenuSelectProps) { - const { control } = useRemixFormContext(); - - return ; -} diff --git a/packages/components/src/ui/dropdown-menu-select-field.tsx b/packages/components/src/ui/dropdown-menu-select-field.tsx deleted file mode 100644 index 8ca4fb28..00000000 --- a/packages/components/src/ui/dropdown-menu-select-field.tsx +++ /dev/null @@ -1,155 +0,0 @@ -// biome-ignore lint/style/noNamespaceImport: from Radix -import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; -import type * as React from 'react'; -import { createContext, useContext, useState } from 'react'; -import type { Control, FieldPath, FieldValues } from 'react-hook-form'; -import { Button } from './button'; -import { DropdownMenuContent } from './dropdown-menu'; -import { - DropdownMenuCheckboxItem as BaseDropdownMenuCheckboxItem, - DropdownMenuItem as BaseDropdownMenuItem, - DropdownMenuRadioItem as BaseDropdownMenuRadioItem, -} from './dropdown-menu'; -import { - type FieldComponents, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from './form'; - -export interface DropdownMenuSelectProps< - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, -> extends Omit, 'onChange' | 'value'> { - control?: Control; - name: TName; - label?: string; - description?: string; - children: React.ReactNode; - className?: string; - labelClassName?: string; - dropdownClassName?: string; - components?: Partial; -} - -export function DropdownMenuSelectField< - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, ->({ - control, - name, - label, - description, - children, - className, - labelClassName, - dropdownClassName, - components, - ...props -}: DropdownMenuSelectProps) { - const [open, setOpen] = useState(false); - - return ( - ( - - {label && ( - - {label} - - )} - - - - - - - {children} - - - - {description && {description}} - {fieldState.error?.message} - - )} - /> - ); -} - -DropdownMenuSelectField.displayName = 'DropdownMenuSelect'; - -// Context to wire menu items to form field -interface DropdownMenuSelectContextValue { - onValueChange: (value: T) => void; - value: T; -} -const DropdownMenuSelectContext = createContext | null>(null); - -/** Hook to access select context in item wrappers */ -export function useDropdownMenuSelectContext() { - const ctx = useContext(DropdownMenuSelectContext); - if (!ctx) { - throw new Error('useDropdownMenuSelectContext must be used within DropdownMenuSelectField'); - } - return ctx as { onValueChange: (value: T) => void; value: T }; -} - -/** Single-select menu item */ -export function DropdownMenuSelectItem({ - value, - children, - ...props -}: { value: string; children: React.ReactNode } & React.ComponentProps) { - const { onValueChange } = useDropdownMenuSelectContext(); - return ( - onValueChange(value)}> - {children} - - ); -} - -/** Multi-select checkbox menu item */ -export function DropdownMenuSelectCheckboxItem({ - value, - children, - ...props -}: { value: string; children: React.ReactNode } & React.ComponentProps) { - const { onValueChange, value: selected } = useDropdownMenuSelectContext(); - const isChecked = Array.isArray(selected) && selected.includes(value); - const handleChange = () => { - const newValue = isChecked ? selected.filter((v) => v !== value) : [...(selected || []), value]; - onValueChange(newValue); - }; - return ( - - {children} - - ); -} - -/** Radio-select menu item */ -export function DropdownMenuSelectRadioItem({ - value: itemValue, - children, - ...props -}: { value: string; children: React.ReactNode } & React.ComponentProps) { - const { onValueChange } = useDropdownMenuSelectContext(); - return ( - onValueChange(itemValue)}> - {children} - - ); -} From 42c0992f0bcadb22649ae9334effae0b0716f2ce Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Mon, 18 Aug 2025 20:49:28 +0000 Subject: [PATCH 16/24] Fix: Remove references to deleted dropdown-menu-select and region-select components --- .../components/src/remix-hook-form/index.ts | 2 +- .../src/remix-hook-form/region-select.tsx | 43 ----- packages/components/src/ui/index.ts | 2 +- packages/components/src/ui/region-select.tsx | 163 ------------------ 4 files changed, 2 insertions(+), 208 deletions(-) delete mode 100644 packages/components/src/remix-hook-form/region-select.tsx delete mode 100644 packages/components/src/ui/region-select.tsx diff --git a/packages/components/src/remix-hook-form/index.ts b/packages/components/src/remix-hook-form/index.ts index 9bbef685..fe23071f 100644 --- a/packages/components/src/remix-hook-form/index.ts +++ b/packages/components/src/remix-hook-form/index.ts @@ -2,7 +2,6 @@ export * from './checkbox'; export * from './form'; export * from './form-error'; export * from './date-picker'; -export * from './dropdown-menu-select'; export * from './phone-input'; export * from './text-field'; export * from './password-field'; @@ -18,3 +17,4 @@ 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/region-select.tsx b/packages/components/src/remix-hook-form/region-select.tsx deleted file mode 100644 index 7b0b65e6..00000000 --- a/packages/components/src/remix-hook-form/region-select.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import * as React from 'react'; -import { useRemixFormContext } from 'remix-hook-form'; -import { FormControl, FormDescription, FormLabel, FormMessage } from './form'; -import { FormField, FormItem } from '../ui/form'; -import { RegionSelect as UIRegionSelect, type RegionSelectProps as UIRegionSelectProps } from '../ui/region-select'; - -export interface RegionSelectProps extends Omit { - name: string; - label?: string; - description?: string; - className?: string; -} - -export function RegionSelect({ - name, - label, - description, - className, - ...props -}: RegionSelectProps) { - const { control } = useRemixFormContext(); - - return ( - ( - - {label && {label}} - - - - {description && {description}} - - - )} - /> - ); -} diff --git a/packages/components/src/ui/index.ts b/packages/components/src/ui/index.ts index e388b46f..0c32f085 100644 --- a/packages/components/src/ui/index.ts +++ b/packages/components/src/ui/index.ts @@ -4,7 +4,6 @@ export * from './checkbox-field'; export * from './date-picker'; export * from './date-picker-field'; export * from './dropdown-menu'; -export * from './dropdown-menu-select-field'; export * from './form'; export * from './form-error-field'; export * from './label'; @@ -34,3 +33,4 @@ 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/region-select.tsx b/packages/components/src/ui/region-select.tsx deleted file mode 100644 index bdd32d05..00000000 --- a/packages/components/src/ui/region-select.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import * as React from 'react'; -import { Check, ChevronDown } from 'lucide-react'; -import { cn } from './utils'; -import { useOverlayTriggerState } from 'react-stately'; -import { Popover } from '@radix-ui/react-popover'; -import { - PopoverContent, - PopoverTrigger, -} from './popover'; - -export interface RegionOption { - label: string; - value: string; -} - -export interface RegionSelectProps { - options: RegionOption[]; - value?: string; - onValueChange?: (value: string) => void; - placeholder?: string; - disabled?: boolean; - className?: string; - contentClassName?: string; - itemClassName?: string; -} - -export function RegionSelect({ - options, - value, - onValueChange, - placeholder = 'Select a region', - disabled = false, - className, - contentClassName, - itemClassName, -}: RegionSelectProps) { - const popoverState = useOverlayTriggerState({}); - const [query, setQuery] = React.useState(''); - const triggerRef = React.useRef(null); - const popoverRef = React.useRef(null); - const selectedItemRef = React.useRef(null); - const [menuWidth, setMenuWidth] = React.useState(undefined); - - React.useEffect(() => { - if (triggerRef.current) setMenuWidth(triggerRef.current.offsetWidth); - }, []); - - // Scroll to selected item when dropdown opens - React.useEffect(() => { - if (popoverState.isOpen && selectedItemRef.current) { - // Use setTimeout to ensure the DOM is fully rendered - setTimeout(() => { - selectedItemRef.current?.scrollIntoView({ block: 'nearest' }); - }, 0); - } - }, [popoverState.isOpen]); - - const selectedOption = options.find((o) => o.value === value); - - const filtered = React.useMemo( - () => (query ? options.filter((o) => `${o.label}`.toLowerCase().includes(query.trim().toLowerCase())) : options), - [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]); - - return ( - - - {selectedOption?.label || placeholder} - - - -
-
- setQuery(e.target.value)} - placeholder="Search..." - // focus after mount for accessibility without using autoFocus - ref={(el) => { - if (el) queueMicrotask(() => el.focus()); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - const toSelect = enterCandidate; - if (toSelect) { - onValueChange?.(toSelect.value); - setQuery(''); - popoverState.close(); - triggerRef.current?.focus(); - } - } else if (e.key === 'Escape') { - e.preventDefault(); - setQuery(''); - popoverState.close(); - triggerRef.current?.focus(); - } - }} - 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) => { - const isSelected = option.value === value; - const isEnterCandidate = query.trim() !== '' && enterCandidate?.value === option.value && !isSelected; - return ( -
  • - -
  • - ); - })} -
-
-
-
- ); -} - From 6b656e1f255c4e9c95e9422fdf818146c5a91106 Mon Sep 17 00:00:00 2001 From: Jake Ruesink Date: Mon, 18 Aug 2025 16:37:14 -0500 Subject: [PATCH 17/24] Update package.json to include Playwright for testing and streamline workspaces configuration. Refactor select component imports and improve accessibility features in select.tsx and phone-input-field.tsx. Enhance Storybook stories for region selection components with better querying methods. --- .../src/remix-hook-form/select.stories.tsx | 73 ++++++++++--------- package.json | 6 +- .../components/src/ui/phone-input-field.tsx | 6 +- packages/components/src/ui/select.tsx | 60 ++++++++------- yarn.lock | 14 +++- 5 files changed, 88 insertions(+), 71 deletions(-) diff --git a/apps/docs/src/remix-hook-form/select.stories.tsx b/apps/docs/src/remix-hook-form/select.stories.tsx index b8d793eb..d11ef0cb 100644 --- a/apps/docs/src/remix-hook-form/select.stories.tsx +++ b/apps/docs/src/remix-hook-form/select.stories.tsx @@ -1,8 +1,8 @@ import { zodResolver } from '@hookform/resolvers/zod'; -import { USStateSelect, CanadaProvinceSelect, Select } from '@lambdacurry/forms/remix-hook-form'; -import { US_STATES } from '@lambdacurry/forms/ui/data/us-states'; -import { CANADA_PROVINCES } from '@lambdacurry/forms/ui/data/canada-provinces'; +import { CanadaProvinceSelect, Select, USStateSelect } from '@lambdacurry/forms/remix-hook-form'; 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'; @@ -20,7 +20,7 @@ type FormData = z.infer; const RegionSelectExample = () => { const fetcher = useFetcher<{ message: string; selectedRegions: Record }>(); - + const methods = useRemixForm({ resolver: zodResolver(formSchema), defaultValues: { @@ -36,32 +36,21 @@ const RegionSelectExample = () => {
- - - - + + + + + ), +); +PurpleSearchInput.displayName = 'PurpleSearchInput'; + +// Custom Item (green themed) for the fruit example +const GreenItem = React.forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes & { selected?: boolean } +>((props, ref) => ( + + +
- setQuery(e.target.value)} placeholder="Search..." - // focus after mount for accessibility without using autoFocus ref={(el) => { if (el) queueMicrotask(() => el.focus()); }} @@ -135,8 +171,7 @@ export function Select({ const isEnterCandidate = query.trim() !== '' && enterCandidate?.value === option.value && !isSelected; return (
  • - +
  • ); })} From a22872310c5ffa7a0d61a6d84add0ab9f2db457b Mon Sep 17 00:00:00 2001 From: Jake Ruesink Date: Mon, 18 Aug 2025 22:15:10 -0500 Subject: [PATCH 20/24] Update AGENTS.md to include new versioning guidelines and bump package version to 0.19.4 in package.json. --- .cursor/rules/versioning-with-npm.mdc | 74 +++++++++++++++++++++++++++ AGENTS.md | 1 + packages/components/package.json | 2 +- 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 .cursor/rules/versioning-with-npm.mdc diff --git a/.cursor/rules/versioning-with-npm.mdc b/.cursor/rules/versioning-with-npm.mdc new file mode 100644 index 00000000..cfa2eb25 --- /dev/null +++ b/.cursor/rules/versioning-with-npm.mdc @@ -0,0 +1,74 @@ + +You are an expert release manager for a Yarn 4 monorepo who uses the npm CLI for quick version bumps and patch releases. + +# Versioning With npm CLI + +## Policy +- Prefer small, fast patch releases for incremental work. +- Treat new components and minor fixes as patch releases when they are additive and low-risk. +- Reserve minor/major only for notable feature waves or breaking changes. + +Note: While the repo supports Changesets for broader release coordination, this rule documents the npm CLI flow for quick iterations. + +## What Counts As β€œSmall” +- Additive components (new UI or form wrappers) without breaking changes +- Bug fixes, perf tweaks, a11y refinements, copy/docs updates +- Internal refactors that don’t change public APIs + +## Pre-flight +- Clean working tree: no uncommitted changes +- On a release-worthy branch (e.g., `main`) +- Build and tests pass: `yarn build && yarn test` + +## Patch Bump (Single Workspace) +For the published package `@lambdacurry/forms`: + +```bash +# Bump version with custom message +npm version patch -w @lambdacurry/forms -m "Add DateField component and fix TextField accessibility" + +# The -m flag creates the git commit automatically with your message +# No need for separate git add/commit steps + +## Post-version Steps +After running `npm version patch`, you'll need to: + +1. **Return to top level**: `cd ../..` (if you're in the package directory) +2. **Update lockfile**: `yarn install` to update `yarn.lock` with the new version +3. **Commit lockfile**: `git add yarn.lock && git commit -m "Update yarn.lock for @lambdacurry/forms vX.Y.Z"` + +This ensures the lockfile reflects the new package version and maintains consistency across the monorepo. +``` + +Guidelines: +- Keep the summary one line and human-readable. +- Examples: "Add DateField; fix TextField aria; smaller bundle". +- This updates `packages/components/package.json` and creates a normal commit without tags. + +## Open PR and Merge +- Push your branch and open a PR. +- When the PR merges into `main`, GitHub CI publishes the package. No manual tagging or `npm publish` needed. + +## Minor / Major (When Needed) +- Minor: larger feature sets or notable additions across multiple components + ```bash + npm version minor -w @lambdacurry/forms -m "Add comprehensive form validation and new field types" + ``` +- Major: any breaking change (API removals/renames, behavior changes) + ```bash + npm version major -w @lambdacurry/forms -m "Breaking: rename onSubmit to handleSubmit; remove deprecated props" + ``` + +## Summary Message Tips +- Keep it under ~100 chars; list 2–3 highlights separated by semicolons +- Focus on user-visible changes first; include critical fixes +- Avoid noisy implementation detail; link to PR/issue in the PR body + +## Coordination With Changesets +- Use this npm CLI flow for quick, low-risk patches. +- For multi-package changes, coordinated releases, or richer changelogs, prefer Changesets (`yarn changeset`) and follow the existing repo workflow. + + +## Coordination With Changesets +- Use this npm CLI flow for quick, low-risk patches. +- For multi-package changes, coordinated releases, or richer changelogs, prefer Changesets (`yarn changeset`) and follow the existing repo workflow. diff --git a/AGENTS.md b/AGENTS.md index ffdb5561..6fac22a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,7 @@ - `.cursor/rules/form-component-patterns.mdc`: Remix Hook Form + Zod wrappers, errors, server actions. - `.cursor/rules/storybook-testing.mdc`: Storybook play tests, router stub decorator, local/CI flows. - `.cursor/rules/monorepo-organization.mdc`: Imports/exports, package boundaries, Turbo/Vite/TS paths. +- `.cursor/rules/versioning-with-npm.mdc`: npm CLI version bumps (patch-first), CI publishes on merge. When to review before starting work - Building/refactoring UI components: react-typescript-patterns + ui-component-patterns. diff --git a/packages/components/package.json b/packages/components/package.json index d7c135ca..9967e532 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@lambdacurry/forms", - "version": "0.19.3", + "version": "0.19.4", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", From e7cd61778610b5fc3434ff36dd1bc9b112124fc2 Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Tue, 19 Aug 2025 05:10:51 +0000 Subject: [PATCH 21/24] Add wildcard exports to package.json for subpath imports --- packages/components/package.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/components/package.json b/packages/components/package.json index 9967e532..40735d67 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -13,9 +13,17 @@ "types": "./dist/remix-hook-form/index.d.ts", "import": "./dist/remix-hook-form/index.js" }, + "./remix-hook-form/*": { + "types": "./dist/remix-hook-form/*.d.ts", + "import": "./dist/remix-hook-form/*.js" + }, "./ui": { "types": "./dist/ui/index.d.ts", "import": "./dist/ui/index.js" + }, + "./ui/*": { + "types": "./dist/ui/*.d.ts", + "import": "./dist/ui/*.js" } }, "files": [ From 56b14c4d9a025e5265fcea0bc466a4ea44db432e Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Tue, 19 Aug 2025 05:51:48 +0000 Subject: [PATCH 22/24] Fix select component tests by updating test imports and selectors --- .../remix-hook-form/select-custom.stories.tsx | 1 + .../src/remix-hook-form/select.stories.tsx | 176 +----------------- apps/docs/src/remix-hook-form/select.test.tsx | 13 +- 3 files changed, 13 insertions(+), 177 deletions(-) diff --git a/apps/docs/src/remix-hook-form/select-custom.stories.tsx b/apps/docs/src/remix-hook-form/select-custom.stories.tsx index d543ce41..41d98c18 100644 --- a/apps/docs/src/remix-hook-form/select-custom.stories.tsx +++ b/apps/docs/src/remix-hook-form/select-custom.stories.tsx @@ -251,3 +251,4 @@ Each custom component should use React.forwardRef to preserve focus, ARIA, and k }); }, }; + diff --git a/apps/docs/src/remix-hook-form/select.stories.tsx b/apps/docs/src/remix-hook-form/select.stories.tsx index 829c94ba..b0781470 100644 --- a/apps/docs/src/remix-hook-form/select.stories.tsx +++ b/apps/docs/src/remix-hook-form/select.stories.tsx @@ -9,6 +9,7 @@ import { type ActionFunctionArgs, useFetcher } from 'react-router'; import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form'; import { z } from 'zod'; import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub'; +import { testCanadaProvinceSelection, testFormSubmission, testUSStateSelection, testValidationErrors } from './select.test'; const formSchema = z.object({ state: z.string().min(1, 'Please select a state'), @@ -172,179 +173,14 @@ const RegionSelectExample = () => { }, decorators: [selectRouterDecorator], play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step('Verify initial state', () => { - // Verify all selects are empty initially - const stateSelect = canvas.getByLabelText('US State'); - const provinceSelect = canvas.getByLabelText('Canadian Province'); - const regionSelect = canvas.getByLabelText('Custom Region'); - - expect(stateSelect).toHaveTextContent('Select a state'); - expect(provinceSelect).toHaveTextContent('Select a province'); - expect(regionSelect).toHaveTextContent('Select a custom region'); - - // Verify submit button is present - const submitButton = canvas.getByRole('button', { name: 'Submit' }); - expect(submitButton).toBeInTheDocument(); - }); - - await step('Test validation errors on invalid submission', async () => { - // Submit form without selecting any options - const submitButton = canvas.getByRole('button', { name: 'Submit' }); - await userEvent.click(submitButton); - - // Verify validation error messages appear - 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(); - }); - - await step('Test successful submission', async () => { - // Select a state - const stateSelect = canvas.getByLabelText('US State'); - await userEvent.click(stateSelect); - { - const listbox = await within(document.body).findByRole('listbox'); - const californiaOption = within(listbox).getByRole('option', { name: 'California' }); - await userEvent.click(californiaOption); - } - - // Select a province - const provinceSelect = canvas.getByLabelText('Canadian Province'); - await userEvent.click(provinceSelect); - { - const listbox = await within(document.body).findByRole('listbox'); - const ontarioOption = within(listbox).getByRole('option', { name: 'Ontario' }); - await userEvent.click(ontarioOption); - } - - // Select a custom region - const regionSelect = canvas.getByLabelText('Custom Region'); - await userEvent.click(regionSelect); - { - const listbox = await within(document.body).findByRole('listbox'); - const customOption = within(listbox).getByRole('option', { name: 'California' }); - await userEvent.click(customOption); - } - - // Submit - const submitButton = canvas.getByRole('button', { name: 'Submit' }); - await userEvent.click(submitButton); - - // Assert success UI - await expect(canvas.findByText('Selected regions:')).resolves.toBeInTheDocument(); - expect(canvas.getByText('state: CA')).toBeInTheDocument(); - expect(canvas.getByText('province: ON')).toBeInTheDocument(); - expect(canvas.getByText('region: CA')).toBeInTheDocument(); - }); + await step('Test US State Selection', testUSStateSelection); + await step('Test Canada Province Selection', testCanadaProvinceSelection); + await step('Test Form Submission', testFormSubmission); }, }; -export const USStateSelection: Story = { - parameters: { - docs: { - description: { - story: 'Test selecting a US state from the dropdown.', - }, - }, - }, - decorators: [selectRouterDecorator], - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step('Select a US state', async () => { - // Find and click the US state dropdown - const stateSelect = canvas.getByLabelText('US State'); - await userEvent.click(stateSelect); - - // Dropdown content renders in a portal; query via document.body roles - const listbox = await within(document.body).findByRole('listbox'); - const californiaOption = within(listbox).getByRole('option', { name: 'California' }); - await userEvent.click(californiaOption); - - // Verify the selection - expect(stateSelect).toHaveTextContent('California'); - }); - }, -}; - -export const CanadaProvinceSelection: Story = { - parameters: { - docs: { - description: { - story: 'Test selecting a Canadian province from the dropdown.', - }, - }, - }, +export const ValidationErrors: Story = { decorators: [selectRouterDecorator], - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step('Select a Canadian province', async () => { - // Find and click the Canada province dropdown - const provinceSelect = canvas.getByLabelText('Canadian Province'); - await userEvent.click(provinceSelect); - - // Query in portal content by role - const listbox = await within(document.body).findByRole('listbox'); - const ontarioOption = within(listbox).getByRole('option', { name: 'Ontario' }); - await userEvent.click(ontarioOption); - - // Verify the selection - expect(provinceSelect).toHaveTextContent('Ontario'); - }); - }, + play: testValidationErrors, }; -export const FormSubmission: Story = { - parameters: { - docs: { - description: { - story: 'Test form submission with selected regions.', - }, - }, - }, - decorators: [selectRouterDecorator], - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step('Select all regions', async () => { - // Select a state - const stateSelect = canvas.getByLabelText('US State'); - await userEvent.click(stateSelect); - { - const listbox = await within(document.body).findByRole('listbox'); - const californiaOption = within(listbox).getByRole('option', { name: 'California' }); - await userEvent.click(californiaOption); - } - - // Select a province - const provinceSelect = canvas.getByLabelText('Canadian Province'); - await userEvent.click(provinceSelect); - { - const listbox = await within(document.body).findByRole('listbox'); - const ontarioOption = within(listbox).getByRole('option', { name: 'Ontario' }); - await userEvent.click(ontarioOption); - } - - // Select a custom region - const regionSelect = canvas.getByLabelText('Custom Region'); - await userEvent.click(regionSelect); - { - const listbox = await within(document.body).findByRole('listbox'); - const customOption = within(listbox).getByRole('option', { name: 'California' }); - await userEvent.click(customOption); - } - }); - - await step('Submit the form', async () => { - // Submit the form - const submitButton = canvas.getByRole('button', { name: 'Submit' }); - await userEvent.click(submitButton); - - // Verify the submission result - await expect(canvas.findByText('Selected regions:')).resolves.toBeInTheDocument(); - }); - }, -}; diff --git a/apps/docs/src/remix-hook-form/select.test.tsx b/apps/docs/src/remix-hook-form/select.test.tsx index 7e502e7e..91770327 100644 --- a/apps/docs/src/remix-hook-form/select.test.tsx +++ b/apps/docs/src/remix-hook-form/select.test.tsx @@ -1,5 +1,4 @@ -import { expect } from '@storybook/test'; -import { userEvent, within } from '@storybook/testing-library'; +import { expect, userEvent, within } from '@storybook/test'; import { StoryContext } from '@storybook/react'; // Test selecting a US state @@ -11,7 +10,7 @@ export const testUSStateSelection = async ({ canvasElement }: StoryContext) => { await userEvent.click(stateDropdown); // Select a state (e.g., California) - const californiaOption = await canvas.findByText('California'); + const californiaOption = await within(document.body).findByRole('option', { name: 'California' }); await userEvent.click(californiaOption); // Verify the selection @@ -27,7 +26,7 @@ export const testCanadaProvinceSelection = async ({ canvasElement }: StoryContex await userEvent.click(provinceDropdown); // Select a province (e.g., Ontario) - const ontarioOption = await canvas.findByText('Ontario'); + const ontarioOption = await within(document.body).findByRole('option', { name: 'Ontario' }); await userEvent.click(ontarioOption); // Verify the selection @@ -41,19 +40,19 @@ export const testFormSubmission = async ({ canvasElement }: StoryContext) => { // Select a state const stateDropdown = canvas.getByLabelText('US State'); await userEvent.click(stateDropdown); - const californiaOption = await canvas.findByText('California'); + const californiaOption = await within(document.body).findByRole('option', { name: 'California' }); await userEvent.click(californiaOption); // Select a province const provinceDropdown = canvas.getByLabelText('Canadian Province'); await userEvent.click(provinceDropdown); - const ontarioOption = await canvas.findByText('Ontario'); + const ontarioOption = await within(document.body).findByRole('option', { name: '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'); + const customOption = await within(document.body).findByRole('option', { name: 'New York' }); await userEvent.click(customOption); // Submit the form From 62a0150006676605cc89a892ade342fa66629733 Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Tue, 19 Aug 2025 14:05:59 +0000 Subject: [PATCH 23/24] Add delay after selection to fix dropdown tests --- .../src/remix-hook-form/select-custom.stories.tsx | 5 +++++ apps/docs/src/remix-hook-form/select.test.tsx | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/apps/docs/src/remix-hook-form/select-custom.stories.tsx b/apps/docs/src/remix-hook-form/select-custom.stories.tsx index 41d98c18..27c4cf1e 100644 --- a/apps/docs/src/remix-hook-form/select-custom.stories.tsx +++ b/apps/docs/src/remix-hook-form/select-custom.stories.tsx @@ -9,6 +9,9 @@ import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hoo import { z } from 'zod'; import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub'; +// Helper function to wait for a short time +const waitForSelection = async (ms = 50) => new Promise(resolve => setTimeout(resolve, ms)); + const formSchema = z.object({ region: z.string().min(1, 'Please select a region'), theme: z.string().min(1, 'Please select a theme'), @@ -233,6 +236,7 @@ Each custom component should use React.forwardRef to preserve focus, ARIA, and k await userEvent.click(themeSelect); const purple = await within(document.body).findByRole('option', { name: 'Purple' }); await userEvent.click(purple); + await waitForSelection(); expect(themeSelect).toHaveTextContent('Purple'); }); @@ -241,6 +245,7 @@ Each custom component should use React.forwardRef to preserve focus, ARIA, and k await userEvent.click(fruitSelect); const banana = await within(document.body).findByRole('option', { name: '🍌 Banana' }); await userEvent.click(banana); + await waitForSelection(); expect(fruitSelect).toHaveTextContent('🍌 Banana'); }); diff --git a/apps/docs/src/remix-hook-form/select.test.tsx b/apps/docs/src/remix-hook-form/select.test.tsx index 91770327..e1ca50be 100644 --- a/apps/docs/src/remix-hook-form/select.test.tsx +++ b/apps/docs/src/remix-hook-form/select.test.tsx @@ -1,6 +1,9 @@ import { expect, userEvent, within } from '@storybook/test'; import { StoryContext } from '@storybook/react'; +// Helper function to wait for a short time +const waitForSelection = async (ms = 50) => new Promise(resolve => setTimeout(resolve, ms)); + // Test selecting a US state export const testUSStateSelection = async ({ canvasElement }: StoryContext) => { const canvas = within(canvasElement); @@ -13,6 +16,9 @@ export const testUSStateSelection = async ({ canvasElement }: StoryContext) => { const californiaOption = await within(document.body).findByRole('option', { name: 'California' }); await userEvent.click(californiaOption); + // Wait for the selection to be applied + await waitForSelection(); + // Verify the selection expect(stateDropdown).toHaveTextContent('California'); }; @@ -29,6 +35,9 @@ export const testCanadaProvinceSelection = async ({ canvasElement }: StoryContex const ontarioOption = await within(document.body).findByRole('option', { name: 'Ontario' }); await userEvent.click(ontarioOption); + // Wait for the selection to be applied + await waitForSelection(); + // Verify the selection expect(provinceDropdown).toHaveTextContent('Ontario'); }; @@ -42,18 +51,21 @@ export const testFormSubmission = async ({ canvasElement }: StoryContext) => { await userEvent.click(stateDropdown); const californiaOption = await within(document.body).findByRole('option', { name: 'California' }); await userEvent.click(californiaOption); + await waitForSelection(); // Select a province const provinceDropdown = canvas.getByLabelText('Canadian Province'); await userEvent.click(provinceDropdown); const ontarioOption = await within(document.body).findByRole('option', { name: 'Ontario' }); await userEvent.click(ontarioOption); + await waitForSelection(); // Select a custom region const regionDropdown = canvas.getByLabelText('Custom Region'); await userEvent.click(regionDropdown); const customOption = await within(document.body).findByRole('option', { name: 'New York' }); await userEvent.click(customOption); + await waitForSelection(); // Submit the form const submitButton = canvas.getByRole('button', { name: 'Submit' }); From 98b0b752edfbaf8f0d4778e6f11ee8718ab5e0f2 Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Tue, 19 Aug 2025 14:17:07 +0000 Subject: [PATCH 24/24] Fix Select component ARIA roles and test semantics --- .../remix-hook-form/select-custom.stories.tsx | 9 ++------ apps/docs/src/remix-hook-form/select.test.tsx | 23 ++++++------------- packages/components/src/ui/select.tsx | 10 ++++---- 3 files changed, 14 insertions(+), 28 deletions(-) diff --git a/apps/docs/src/remix-hook-form/select-custom.stories.tsx b/apps/docs/src/remix-hook-form/select-custom.stories.tsx index 27c4cf1e..d62f7382 100644 --- a/apps/docs/src/remix-hook-form/select-custom.stories.tsx +++ b/apps/docs/src/remix-hook-form/select-custom.stories.tsx @@ -9,9 +9,6 @@ import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hoo import { z } from 'zod'; import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub'; -// Helper function to wait for a short time -const waitForSelection = async (ms = 50) => new Promise(resolve => setTimeout(resolve, ms)); - const formSchema = z.object({ region: z.string().min(1, 'Please select a region'), theme: z.string().min(1, 'Please select a theme'), @@ -236,8 +233,7 @@ Each custom component should use React.forwardRef to preserve focus, ARIA, and k await userEvent.click(themeSelect); const purple = await within(document.body).findByRole('option', { name: 'Purple' }); await userEvent.click(purple); - await waitForSelection(); - expect(themeSelect).toHaveTextContent('Purple'); + await expect(themeSelect).toHaveTextContent('Purple'); }); await step('Open and choose Fruit', async () => { @@ -245,8 +241,7 @@ Each custom component should use React.forwardRef to preserve focus, ARIA, and k await userEvent.click(fruitSelect); const banana = await within(document.body).findByRole('option', { name: '🍌 Banana' }); await userEvent.click(banana); - await waitForSelection(); - expect(fruitSelect).toHaveTextContent('🍌 Banana'); + await expect(fruitSelect).toHaveTextContent('🍌 Banana'); }); await step('Submit the form', async () => { diff --git a/apps/docs/src/remix-hook-form/select.test.tsx b/apps/docs/src/remix-hook-form/select.test.tsx index e1ca50be..261505f3 100644 --- a/apps/docs/src/remix-hook-form/select.test.tsx +++ b/apps/docs/src/remix-hook-form/select.test.tsx @@ -1,9 +1,6 @@ import { expect, userEvent, within } from '@storybook/test'; import { StoryContext } from '@storybook/react'; -// Helper function to wait for a short time -const waitForSelection = async (ms = 50) => new Promise(resolve => setTimeout(resolve, ms)); - // Test selecting a US state export const testUSStateSelection = async ({ canvasElement }: StoryContext) => { const canvas = within(canvasElement); @@ -16,11 +13,8 @@ export const testUSStateSelection = async ({ canvasElement }: StoryContext) => { const californiaOption = await within(document.body).findByRole('option', { name: 'California' }); await userEvent.click(californiaOption); - // Wait for the selection to be applied - await waitForSelection(); - - // Verify the selection - expect(stateDropdown).toHaveTextContent('California'); + // Wait for the popover to close and the selection to be applied + await expect(stateDropdown).toHaveTextContent('California'); }; // Test selecting a Canadian province @@ -35,11 +29,8 @@ export const testCanadaProvinceSelection = async ({ canvasElement }: StoryContex const ontarioOption = await within(document.body).findByRole('option', { name: 'Ontario' }); await userEvent.click(ontarioOption); - // Wait for the selection to be applied - await waitForSelection(); - - // Verify the selection - expect(provinceDropdown).toHaveTextContent('Ontario'); + // Wait for the popover to close and the selection to be applied + await expect(provinceDropdown).toHaveTextContent('Ontario'); }; // Test form submission @@ -51,21 +42,21 @@ export const testFormSubmission = async ({ canvasElement }: StoryContext) => { await userEvent.click(stateDropdown); const californiaOption = await within(document.body).findByRole('option', { name: 'California' }); await userEvent.click(californiaOption); - await waitForSelection(); + await expect(stateDropdown).toHaveTextContent('California'); // Select a province const provinceDropdown = canvas.getByLabelText('Canadian Province'); await userEvent.click(provinceDropdown); const ontarioOption = await within(document.body).findByRole('option', { name: 'Ontario' }); await userEvent.click(ontarioOption); - await waitForSelection(); + await expect(provinceDropdown).toHaveTextContent('Ontario'); // Select a custom region const regionDropdown = canvas.getByLabelText('Custom Region'); await userEvent.click(regionDropdown); const customOption = await within(document.body).findByRole('option', { name: 'New York' }); await userEvent.click(customOption); - await waitForSelection(); + await expect(regionDropdown).toHaveTextContent('New York'); // Submit the form const submitButton = canvas.getByRole('button', { name: 'Submit' }); diff --git a/packages/components/src/ui/select.tsx b/packages/components/src/ui/select.tsx index 573e0bee..7ef013ab 100644 --- a/packages/components/src/ui/select.tsx +++ b/packages/components/src/ui/select.tsx @@ -51,6 +51,7 @@ export function Select({ const triggerRef = React.useRef(null); const popoverRef = React.useRef(null); const selectedItemRef = React.useRef(null); + const listboxId = React.useId(); const [menuWidth, setMenuWidth] = React.useState(undefined); React.useEffect(() => { @@ -117,10 +118,10 @@ export function Select({ 'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', className, )} - // biome-ignore lint/a11y/useAriaPropsForRole: using