From 1dab87e2b2a142989d2ced3bbd7e4a2dedc6dbc2 Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Mon, 18 Aug 2025 19:58:32 +0000 Subject: [PATCH 1/9] Refactor region select into a general select component with US state and Canada province helpers --- ...-select.stories.tsx => select.stories.tsx} | 19 +- ...region-select.test.tsx => select.test.tsx} | 0 .../canada-province-select.tsx | 6 +- .../components/src/remix-hook-form/index.ts | 2 +- .../components/src/remix-hook-form/select.tsx | 44 +++ .../src/remix-hook-form/us-state-select.tsx | 6 +- .../src/ui/canada-province-select.tsx | 6 +- packages/components/src/ui/index.ts | 4 +- packages/components/src/ui/select.tsx | 299 +++++++++--------- .../components/src/ui/us-state-select.tsx | 6 +- 10 files changed, 219 insertions(+), 173 deletions(-) rename apps/docs/src/remix-hook-form/{region-select.stories.tsx => select.stories.tsx} (94%) rename apps/docs/src/remix-hook-form/{region-select.test.tsx => select.test.tsx} (100%) create mode 100644 packages/components/src/remix-hook-form/select.tsx diff --git a/apps/docs/src/remix-hook-form/region-select.stories.tsx b/apps/docs/src/remix-hook-form/select.stories.tsx similarity index 94% rename from apps/docs/src/remix-hook-form/region-select.stories.tsx rename to apps/docs/src/remix-hook-form/select.stories.tsx index 4a298bbe..b8d793eb 100644 --- a/apps/docs/src/remix-hook-form/region-select.stories.tsx +++ b/apps/docs/src/remix-hook-form/select.stories.tsx @@ -1,5 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod'; -import { RegionSelect, USStateSelect, CanadaProvinceSelect } from '@lambdacurry/forms/remix-hook-form'; +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 { Button } from '@lambdacurry/forms/ui/button'; @@ -48,7 +48,7 @@ const RegionSelectExample = () => { description="Select a Canadian province" /> - { ...US_STATES.slice(0, 5), ...CANADA_PROVINCES.slice(0, 5), ]} + placeholder="Select a custom region" /> @@ -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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 9/9] 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",