diff --git a/apps/docs/src/remix-hook-form/select-command.stories.tsx b/apps/docs/src/remix-hook-form/select-command.stories.tsx new file mode 100644 index 00000000..fcf91faf --- /dev/null +++ b/apps/docs/src/remix-hook-form/select-command.stories.tsx @@ -0,0 +1,109 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { SelectCommand, CanadaProvinceSelect as RHFCanadaProvinceSelect } from '@lambdacurry/forms/remix-hook-form'; +import { Button } from '@lambdacurry/forms/ui/button'; +import { CANADA_PROVINCES } from '@lambdacurry/forms/ui/data/canada-provinces'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent, within } from '@storybook/test'; +import type { ActionFunctionArgs } 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({ + province: z.string().min(1, 'Please select a province'), + region: z.string().min(1, 'Please select a region'), +}); + +type FormData = z.infer; + +const Example = () => { + const methods = useRemixForm({ + resolver: zodResolver(formSchema), + defaultValues: { + // Preselect a value far down the list to show scroll-into-view on open + province: 'SK', + region: 'BC', + }, + submitConfig: { action: '/', method: 'post' }, + }); + + return ( + +
+
+ {/* Command-based Combobox control */} + + + {/* Keep existing Select (Popover + ul) for comparison */} + +
+ + +
+
+ ); +}; + +const handleFormSubmission = async (request: Request) => { + const { data, errors } = await getValidatedFormData(request, zodResolver(formSchema)); + + if (errors) return { errors }; + return { message: 'ok', data }; +}; + +const meta: Meta = { + title: 'RemixHookForm/SelectCommand (Combobox)', + component: Example, + parameters: { layout: 'centered' }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + decorators: [ + withReactRouterStubDecorator({ + routes: [ + { + path: '/', + Component: Example, + action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request), + }, + ], + }), + ], + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step('Open province select with preselected Saskatchewan', async () => { + const provinceSelect = canvas.getByLabelText('Canadian Province (Current)'); + await userEvent.click(provinceSelect); + + // Ensure listbox is present and Saskatchewan option exists + const listboxes = await within(document.body).findAllByRole('listbox'); + const provinceListbox = listboxes[0]; // First listbox should be the province select + await expect( + within(provinceListbox).findByRole('option', { name: /Saskatchewan/i }), + ).resolves.toBeInTheDocument(); + }); + + await step('Open command combobox', async () => { + const regionSelect = canvas.getByLabelText('Custom Region (Command)'); + await userEvent.click(regionSelect); + + // Ensure listbox is present and British Columbia option exists + const listboxes = await within(document.body).findAllByRole('listbox'); + const commandListbox = listboxes[listboxes.length - 1]; // Last listbox should be the command select + await expect( + within(commandListbox).findByRole('option', { name: /British Columbia/i }), + ).resolves.toBeInTheDocument(); + }); + }, +}; diff --git a/packages/components/src/remix-hook-form/index.ts b/packages/components/src/remix-hook-form/index.ts index 7b095538..f53d0d64 100644 --- a/packages/components/src/remix-hook-form/index.ts +++ b/packages/components/src/remix-hook-form/index.ts @@ -18,6 +18,8 @@ export * from './radio-group'; export * from './radio-group-item'; export * from './select'; export * from './switch'; +export * from './select-command'; + export * from './text-field'; export * from './textarea'; export * from './us-state-select'; diff --git a/packages/components/src/remix-hook-form/select-command.tsx b/packages/components/src/remix-hook-form/select-command.tsx new file mode 100644 index 00000000..c30aba55 --- /dev/null +++ b/packages/components/src/remix-hook-form/select-command.tsx @@ -0,0 +1,39 @@ +import type * as React from 'react'; +import { useRemixFormContext } from 'remix-hook-form'; +import { FormField, FormItem } from '../ui/form'; +import { FormControl, FormDescription, FormLabel, FormMessage } from './form'; +import { type CommandSelectProps, CommandSelect } from '../ui/select-command'; + +export interface SelectCommandProps extends Omit { + name: string; + label?: string; + description?: string; + className?: string; + components?: Partial<{ + FormControl: React.ComponentType>; + FormLabel: React.ComponentType>; + FormDescription: React.ComponentType>; + FormMessage: React.ComponentType>; + }>; +} + +export function SelectCommand({ name, label, description, className, components, ...props }: SelectCommandProps) { + 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 8e2550a8..181161a9 100644 --- a/packages/components/src/ui/index.ts +++ b/packages/components/src/ui/index.ts @@ -24,6 +24,8 @@ export * from './radio-group'; export * from './radio-group-field'; export * from './select'; export * from './separator'; +export * from './select-command'; + export * from './switch'; export * from './switch-field'; export * from './table'; diff --git a/packages/components/src/ui/select-command.tsx b/packages/components/src/ui/select-command.tsx new file mode 100644 index 00000000..f825a844 --- /dev/null +++ b/packages/components/src/ui/select-command.tsx @@ -0,0 +1,134 @@ +import * as PopoverPrimitive from '@radix-ui/react-popover'; +import { Popover } from '@radix-ui/react-popover'; +import { Check as DefaultCheckIcon, ChevronDown as DefaultChevronIcon } from 'lucide-react'; +import * as React from 'react'; +import { PopoverTrigger } from './popover'; +import { cn } from './utils'; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './command'; + +export interface CommandSelectOption { + label: string; + value: string; +} + +export interface CommandSelectProps extends Omit, 'value' | 'onChange'> { + options: CommandSelectOption[]; + value?: string; + onValueChange?: (value: string) => void; + placeholder?: string; + disabled?: boolean; + className?: string; + contentClassName?: string; + itemClassName?: string; + // Icons can be swapped if desired + CheckIcon?: React.ComponentType>; + ChevronIcon?: React.ComponentType>; +} + +export function CommandSelect({ + options, + value, + onValueChange, + placeholder = 'Select an option', + disabled = false, + className, + contentClassName, + itemClassName, + CheckIcon = DefaultCheckIcon, + ChevronIcon = DefaultChevronIcon, + ...buttonProps +}: CommandSelectProps) { + const [open, setOpen] = React.useState(false); + const triggerRef = React.useRef(null); + const listboxId = React.useId(); + + const selectedOption = options.find((o) => o.value === value); + + // Note: scroll-into-view functionality removed due to ref limitations with Command components + // This could be re-implemented using a different approach if needed + + return ( + + + + + + + + + No results. + {/* CommandList renders a div. */} + + + {options.map((option) => { + const isSelected = option.value === value; + return ( + { + onValueChange?.(option.value); + setOpen(false); + // Return focus to trigger for accessibility + requestAnimationFrame(() => triggerRef.current?.focus()); + }} + 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', + isSelected ? 'bg-gray-100' : 'hover:bg-gray-100', + itemClassName, + )} + role="option" + aria-selected={isSelected} + data-value={option.value} + data-testid={`select-option-${option.value}`} + > + {isSelected && } + + {option.label} + + + ); + })} + + + + + + + ); +}