Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions apps/docs/src/remix-hook-form/select-command.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof formSchema>;

const Example = () => {
const methods = useRemixForm<FormData>({
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 (
<RemixFormProvider {...methods}>
<form onSubmit={methods.handleSubmit} className="space-y-6">
<div className="space-y-4">
{/* Command-based Combobox control */}
<SelectCommand
name="region"
label="Custom Region (Command)"
description="Combobox built on Command components"
options={CANADA_PROVINCES}
placeholder="Select a custom region"
/>

{/* Keep existing Select (Popover + ul) for comparison */}
<RHFCanadaProvinceSelect name="province" label="Canadian Province (Current)" description="Existing Select" />
</div>

<Button type="submit">Submit</Button>
</form>
</RemixFormProvider>
);
};

const handleFormSubmission = async (request: Request) => {
const { data, errors } = await getValidatedFormData<FormData>(request, zodResolver(formSchema));

if (errors) return { errors };
return { message: 'ok', data };
};

const meta: Meta<typeof Example> = {
title: 'RemixHookForm/SelectCommand (Combobox)',
component: Example,
parameters: { layout: 'centered' },
tags: ['autodocs'],
};

export default meta;
type Story = StoryObj<typeof meta>;

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();
});
},
};
2 changes: 2 additions & 0 deletions packages/components/src/remix-hook-form/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
39 changes: 39 additions & 0 deletions packages/components/src/remix-hook-form/select-command.tsx
Original file line number Diff line number Diff line change
@@ -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<CommandSelectProps, 'value' | 'onValueChange'> {
name: string;
label?: string;
description?: string;
className?: string;
components?: Partial<{
FormControl: React.ComponentType<React.ComponentProps<typeof FormControl>>;
FormLabel: React.ComponentType<React.ComponentProps<typeof FormLabel>>;
FormDescription: React.ComponentType<React.ComponentProps<typeof FormDescription>>;
FormMessage: React.ComponentType<React.ComponentProps<typeof FormMessage>>;
}>;
}

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

return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem className={className}>
{label && <FormLabel Component={components?.FormLabel}>{label}</FormLabel>}
<FormControl Component={components?.FormControl}>
<CommandSelect {...props} value={field.value} onValueChange={field.onChange} />
</FormControl>
{description && <FormDescription Component={components?.FormDescription}>{description}</FormDescription>}
<FormMessage Component={components?.FormMessage} />
</FormItem>
)}
/>
);
}
2 changes: 2 additions & 0 deletions packages/components/src/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
134 changes: 134 additions & 0 deletions packages/components/src/ui/select-command.tsx
Original file line number Diff line number Diff line change
@@ -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<React.ButtonHTMLAttributes<HTMLButtonElement>, '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<React.SVGProps<SVGSVGElement>>;
ChevronIcon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}

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<HTMLButtonElement>(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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
ref={triggerRef}
type="button"
disabled={disabled}
className={cn(
'flex items-center justify-between w-full sm:text-base rounded-md border border-input bg-background px-3 py-2 h-10 text-sm ring-offset-background',
'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listboxId}
{...buttonProps}
>
{selectedOption?.label || placeholder}
<ChevronIcon className="w-4 h-4 opacity-50" />
</button>
</PopoverTrigger>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align="start"
sideOffset={4}
className={cn(
'z-50 rounded-md border bg-popover text-popover-foreground shadow-md outline-none',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
'p-0 shadow-md border-0 min-w-[8rem]',
contentClassName,
)}
role="listbox"
id={listboxId}
style={{ width: 'var(--radix-popover-trigger-width)' }}
data-slot="popover-content"
>
<Command className="bg-white rounded-md focus:outline-none sm:text-sm w-full">
<CommandInput autoFocus placeholder="Search..." />
<CommandEmpty>No results.</CommandEmpty>
{/* CommandList renders a div. */}
<CommandList className="max-h-[200px] overflow-y-auto rounded-md w-full">
<CommandGroup>
{options.map((option) => {
const isSelected = option.value === value;
return (
<CommandItem
key={option.value}
// Keep a ref on the selected item so we can scroll it into view on open
value={`${option.label} ${option.value}`}
onSelect={() => {
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 && <CheckIcon className="h-4 w-4 flex-shrink-0" />}
<span className={cn('block truncate', !isSelected && 'ml-6', isSelected && 'font-semibold')}>
{option.label}
</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</Popover>
);
}