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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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 4e2a6ae04c940e9cc80405ac0b4b00c1383958e2 Mon Sep 17 00:00:00 2001 From: Jake Ruesink Date: Tue, 19 Aug 2025 12:30:42 -0500 Subject: [PATCH 22/22] Refactor Select component and related stories for improved accessibility and customization. Added support for dynamic aria attributes and enhanced Storybook tests to verify selection updates. Cleaned up unused region selection code and utilized clsx for class management in custom components. --- .../remix-hook-form/select-custom.stories.tsx | 61 +++++++------------ .../src/remix-hook-form/select.stories.tsx | 8 +-- packages/components/src/ui/form.tsx | 34 +++++++---- packages/components/src/ui/select.tsx | 6 ++ 4 files changed, 54 insertions(+), 55 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..c7a39fd1 100644 --- a/apps/docs/src/remix-hook-form/select-custom.stories.tsx +++ b/apps/docs/src/remix-hook-form/select-custom.stories.tsx @@ -3,6 +3,7 @@ import { Select } from '@lambdacurry/forms/remix-hook-form/select'; import { Button } from '@lambdacurry/forms/ui/button'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { expect, userEvent, within } from '@storybook/test'; +import clsx from 'clsx'; import * as React from 'react'; import { type ActionFunctionArgs, useFetcher } from 'react-router'; import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form'; @@ -10,21 +11,12 @@ import { z } from 'zod'; import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub'; const formSchema = z.object({ - region: z.string().min(1, 'Please select a region'), theme: z.string().min(1, 'Please select a theme'), fruit: z.string().min(1, 'Please select a fruit'), }); type FormData = z.infer; -const regionOptions = [ - { label: 'California', value: 'CA' }, - { label: 'Ontario', value: 'ON' }, - { label: 'New York', value: 'NY' }, - { label: 'Quebec', value: 'QC' }, - { label: 'Texas', value: 'TX' }, -]; - const themeOptions = [ { label: 'Default', value: 'default' }, { label: 'Purple', value: 'purple' }, @@ -45,10 +37,10 @@ const PurpleTrigger = React.forwardRef ), ); @@ -63,10 +55,10 @@ const PurpleItem = React.forwardRef< ref={ref} type="button" {...props} - className={ - 'w-full text-left cursor-pointer select-none py-3 px-3 transition-colors duration-150 flex items-center gap-2 rounded text-purple-900 hover:bg-purple-100 data-[selected=true]:bg-purple-100 ' + - (props.className || '') - } + className={clsx( + 'w-full text-left cursor-pointer select-none py-3 px-3 transition-colors duration-150 flex items-center gap-2 rounded text-purple-900 hover:bg-purple-100 data-[selected=true]:bg-purple-100', + props.className, + )} /> )); PurpleItem.displayName = 'PurpleItem'; @@ -77,7 +69,10 @@ const PurpleSearchInput = React.forwardRef ), ); @@ -92,10 +87,10 @@ const GreenItem = React.forwardRef< ref={ref} type="button" {...props} - className={ - 'w-full text-left cursor-pointer select-none py-3 px-3 transition-colors duration-150 flex items-center gap-2 rounded hover:bg-emerald-100 data-[selected=true]:bg-emerald-100 ' + - (props.className || '') - } + className={clsx( + 'w-full text-left cursor-pointer select-none py-3 px-3 transition-colors duration-150 flex items-center gap-2 rounded hover:bg-emerald-100 data-[selected=true]:bg-emerald-100', + props.className, + )} /> )); GreenItem.displayName = 'GreenItem'; @@ -106,7 +101,6 @@ const SelectCustomizationExample = () => { const methods = useRemixForm({ resolver: zodResolver(formSchema), defaultValues: { - region: '', theme: '', fruit: '', }, @@ -118,15 +112,6 @@ const SelectCustomizationExample = () => {
    - {/* Default Select */} - { const themeSelect = canvas.getByLabelText('Theme'); await userEvent.click(themeSelect); - const purple = await within(document.body).findByRole('option', { name: 'Purple' }); - await userEvent.click(purple); - expect(themeSelect).toHaveTextContent('Purple'); + const listbox = await within(document.body).findByRole('listbox'); + await userEvent.click(within(listbox).getByRole('option', { name: /Purple/i })); + await expect(canvas.findByRole('combobox', { name: 'Theme' })).resolves.toHaveTextContent('Purple'); }); await step('Open and choose Fruit', async () => { const fruitSelect = canvas.getByLabelText('Favorite Fruit'); await userEvent.click(fruitSelect); - const banana = await within(document.body).findByRole('option', { name: '🍌 Banana' }); - await userEvent.click(banana); - expect(fruitSelect).toHaveTextContent('🍌 Banana'); + const listbox = await within(document.body).findByRole('listbox'); + await userEvent.click(within(listbox).getByTestId('select-option-banana')); + await expect(canvas.findByRole('combobox', { name: 'Favorite Fruit' })).resolves.toHaveTextContent('Banana'); }); await step('Submit the form', async () => { diff --git a/apps/docs/src/remix-hook-form/select.stories.tsx b/apps/docs/src/remix-hook-form/select.stories.tsx index 829c94ba..d5296b93 100644 --- a/apps/docs/src/remix-hook-form/select.stories.tsx +++ b/apps/docs/src/remix-hook-form/select.stories.tsx @@ -263,8 +263,8 @@ export const USStateSelection: Story = { const californiaOption = within(listbox).getByRole('option', { name: 'California' }); await userEvent.click(californiaOption); - // Verify the selection - expect(stateSelect).toHaveTextContent('California'); + // Wait for the trigger text to update after portal selection + await expect(canvas.findByRole('combobox', { name: 'US State' })).resolves.toHaveTextContent('California'); }); }, }; @@ -291,8 +291,8 @@ export const CanadaProvinceSelection: Story = { const ontarioOption = within(listbox).getByRole('option', { name: 'Ontario' }); await userEvent.click(ontarioOption); - // Verify the selection - expect(provinceSelect).toHaveTextContent('Ontario'); + // Wait for the trigger text to update after portal selection + await expect(canvas.findByRole('combobox', { name: 'Canadian Province' })).resolves.toHaveTextContent('Ontario'); }); }, }; diff --git a/packages/components/src/ui/form.tsx b/packages/components/src/ui/form.tsx index 867b5ce7..0fda05ee 100644 --- a/packages/components/src/ui/form.tsx +++ b/packages/components/src/ui/form.tsx @@ -92,13 +92,24 @@ export interface FormControlProps extends React.ComponentProps { } export function FormControl({ Component, ...props }: FormControlProps) { - const { formItemId, formDescriptionId, formMessageId, error, ...restProps } = props; + const context = React.useContext(FormItemContext); + const { + formItemId: fromPropsId, + formDescriptionId: fromPropsDesc, + formMessageId: fromPropsMsg, + error, + ...restProps + } = props; + + const computedId = fromPropsId ?? context.formItemId; + const computedDescriptionId = fromPropsDesc ?? context.formDescriptionId; + const computedMessageId = fromPropsMsg ?? context.formMessageId; const ariaProps = { - id: formItemId, - 'aria-describedby': error ? `${formDescriptionId} ${formMessageId}` : formDescriptionId, + id: computedId, + 'aria-describedby': error ? `${computedDescriptionId} ${computedMessageId}` : computedDescriptionId, 'aria-invalid': !!error, - }; + } as const; if (Component) { return ; @@ -135,17 +146,14 @@ export interface FormMessageProps extends React.HTMLAttributes; } -export function FormMessage({ - Component, - className, - formMessageId, - error, - children, - ...rest -}: FormMessageProps) { +export function FormMessage({ Component, className, formMessageId, error, children, ...rest }: FormMessageProps) { if (Component) { // Ensure custom props do not leak to DOM by not spreading them - return {children}; + return ( + + {children} + + ); } const body = error ? error : children; diff --git a/packages/components/src/ui/select.tsx b/packages/components/src/ui/select.tsx index 573e0bee..c62399fd 100644 --- a/packages/components/src/ui/select.tsx +++ b/packages/components/src/ui/select.tsx @@ -47,6 +47,7 @@ export function Select({ ...buttonProps }: SelectProps) { const popoverState = useOverlayTriggerState({}); + const listboxId = React.useId(); const [query, setQuery] = React.useState(''); const triggerRef = React.useRef(null); const popoverRef = React.useRef(null); @@ -121,6 +122,8 @@ export function Select({ // biome-ignore lint/a11y/useSemanticElements: using