Skip to content

Commit 222fc4b

Browse files
committed
Enhance DropdownMenuSelect component with context for item selection and refactor storybook example. Updated to use DropdownMenuSelectItem for rendering options and improved play function for better interaction testing.
1 parent 0bf634e commit 222fc4b

2 files changed

Lines changed: 111 additions & 26 deletions

File tree

apps/docs/src/remix-hook-form/dropdown-menu-select.stories.tsx

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { zodResolver } from '@hookform/resolvers/zod';
22
import { DropdownMenuSelect } from '@lambdacurry/forms/remix-hook-form/dropdown-menu-select';
33
import { Button } from '@lambdacurry/forms/ui/button';
4-
import { FormMessage } from '@lambdacurry/forms/ui/form';
4+
import { DropdownMenuSelectItem } from '@lambdacurry/forms/ui/dropdown-menu-select-field';
55
import type { Meta, StoryObj } from '@storybook/react';
6+
import { expect, screen, userEvent, within } from '@storybook/test';
67
import { type ActionFunctionArgs, Form, useFetcher } from 'react-router';
78
import { RemixFormProvider, createFormData, getValidatedFormData, useRemixForm } from 'remix-hook-form';
89
import { z } from 'zod';
@@ -40,7 +41,7 @@ const ControlledDropdownMenuSelectExample = () => {
4041
onValid: (data) => {
4142
fetcher.submit(
4243
createFormData({
43-
selectedFruit: data.fruit,
44+
fruit: data.fruit,
4445
}),
4546
{
4647
method: 'post',
@@ -55,8 +56,13 @@ const ControlledDropdownMenuSelectExample = () => {
5556
<RemixFormProvider {...methods}>
5657
<Form onSubmit={methods.handleSubmit}>
5758
<div className="space-y-4">
58-
<DropdownMenuSelect name="fruit" label="Select a fruit" options={AVAILABLE_FRUITS} />
59-
<FormMessage error={methods.formState.errors.fruit?.message} />
59+
<DropdownMenuSelect name="fruit" label="Select a fruit">
60+
{AVAILABLE_FRUITS.map((fruit) => (
61+
<DropdownMenuSelectItem key={fruit.value} value={fruit.value}>
62+
{fruit.label}
63+
</DropdownMenuSelectItem>
64+
))}
65+
</DropdownMenuSelect>
6066
<Button type="submit" className="mt-4">
6167
Submit
6268
</Button>
@@ -113,22 +119,22 @@ export const Default: Story = {
113119
},
114120
},
115121
},
116-
// play: async ({ canvasElement }) => {
117-
// const canvas = within(canvasElement);
122+
play: async ({ canvasElement }) => {
123+
const canvas = within(canvasElement);
118124

119-
// // Open the dropdown
120-
// const dropdownButton = canvas.getByRole('combobox');
121-
// await userEvent.click(dropdownButton);
125+
// Open the dropdown
126+
const dropdownButton = canvas.getByRole('button', { name: 'Select an option' });
127+
await userEvent.click(dropdownButton);
122128

123-
// // Select an option
124-
// const option = canvas.getByRole('option', { name: 'Banana' });
125-
// await userEvent.click(option);
129+
// Select an option (portal renders outside the canvas)
130+
const option = screen.getByRole('menuitem', { name: 'Banana' });
131+
await userEvent.click(option);
126132

127-
// // Submit the form
128-
// const submitButton = canvas.getByRole('button', { name: 'Submit' });
129-
// await userEvent.click(submitButton);
133+
// Submit the form
134+
const submitButton = canvas.getByRole('button', { name: 'Submit' });
135+
await userEvent.click(submitButton);
130136

131-
// // Check if the selected option is displayed
132-
// await expect(await canvas.findByText('Banana')).toBeInTheDocument();
133-
// },
137+
// Check if the selected option is displayed
138+
await expect(await canvas.findByText('Banana')).toBeInTheDocument();
139+
},
134140
};

packages/components/src/ui/dropdown-menu-select-field.tsx

Lines changed: 87 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
// biome-ignore lint/style/noNamespaceImport: from Radix
22
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
33
import type * as React from 'react';
4+
import { createContext, useContext, useState } from 'react';
45
import type { Control, FieldPath, FieldValues } from 'react-hook-form';
56
import { Button } from './button';
67
import { DropdownMenuContent } from './dropdown-menu';
8+
import {
9+
DropdownMenuCheckboxItem as BaseDropdownMenuCheckboxItem,
10+
DropdownMenuItem as BaseDropdownMenuItem,
11+
DropdownMenuRadioItem as BaseDropdownMenuRadioItem,
12+
} from './dropdown-menu';
713
import {
814
type FieldComponents,
915
FormControl,
@@ -44,25 +50,35 @@ export function DropdownMenuSelectField<
4450
components,
4551
...props
4652
}: DropdownMenuSelectProps<TFieldValues, TName>) {
53+
const [open, setOpen] = useState(false);
54+
4755
return (
4856
<FormField
4957
control={control}
5058
name={name}
51-
render={({ field, fieldState }) => (
59+
render={({ field, fieldState, formState }) => (
5260
<FormItem className={className}>
5361
{label && (
5462
<FormLabel Component={components?.FormLabel} className={labelClassName}>
5563
{label}
5664
</FormLabel>
5765
)}
5866
<FormControl>
59-
<DropdownMenuPrimitive.Root {...field} {...props} data-slot="dropdown-menu-select-root">
60-
<DropdownMenuPrimitive.Trigger asChild>
61-
<Button className={dropdownClassName} data-slot="dropdown-select-trigger">
62-
{field.value ? field.value : 'Select an option'}
63-
</Button>
64-
</DropdownMenuPrimitive.Trigger>
65-
<DropdownMenuContent data-slot="dropdown-select-content">{children}</DropdownMenuContent>
67+
<DropdownMenuPrimitive.Root
68+
{...field}
69+
open={open}
70+
onOpenChange={setOpen}
71+
{...props}
72+
data-slot="dropdown-menu-select-root"
73+
>
74+
<DropdownMenuSelectContext.Provider value={{ onValueChange: field.onChange, value: field.value }}>
75+
<DropdownMenuPrimitive.Trigger asChild>
76+
<Button className={dropdownClassName} data-slot="dropdown-select-trigger">
77+
{field.value ? field.value : 'Select an option'}
78+
</Button>
79+
</DropdownMenuPrimitive.Trigger>
80+
<DropdownMenuContent data-slot="dropdown-select-content">{children}</DropdownMenuContent>
81+
</DropdownMenuSelectContext.Provider>
6682
</DropdownMenuPrimitive.Root>
6783
</FormControl>
6884
{description && <FormDescription Component={components?.FormDescription}>{description}</FormDescription>}
@@ -74,3 +90,66 @@ export function DropdownMenuSelectField<
7490
}
7591

7692
DropdownMenuSelectField.displayName = 'DropdownMenuSelect';
93+
94+
// Context to wire menu items to form field
95+
interface DropdownMenuSelectContextValue<T> {
96+
onValueChange: (value: T) => void;
97+
value: T;
98+
}
99+
const DropdownMenuSelectContext = createContext<DropdownMenuSelectContextValue<unknown> | null>(null);
100+
101+
/** Hook to access select context in item wrappers */
102+
export function useDropdownMenuSelectContext<T = unknown>() {
103+
const ctx = useContext(DropdownMenuSelectContext);
104+
if (!ctx) {
105+
throw new Error('useDropdownMenuSelectContext must be used within DropdownMenuSelectField');
106+
}
107+
return ctx as { onValueChange: (value: T) => void; value: T };
108+
}
109+
110+
/** Single-select menu item */
111+
export function DropdownMenuSelectItem({
112+
value,
113+
children,
114+
...props
115+
}: { value: string; children: React.ReactNode } & React.ComponentProps<typeof BaseDropdownMenuItem>) {
116+
const { onValueChange } = useDropdownMenuSelectContext<string>();
117+
return (
118+
<BaseDropdownMenuItem {...props} onSelect={() => onValueChange(value)}>
119+
{children}
120+
</BaseDropdownMenuItem>
121+
);
122+
}
123+
124+
/** Multi-select checkbox menu item */
125+
export function DropdownMenuSelectCheckboxItem({
126+
value,
127+
children,
128+
...props
129+
}: { value: string; children: React.ReactNode } & React.ComponentProps<typeof BaseDropdownMenuCheckboxItem>) {
130+
const { onValueChange, value: selected } = useDropdownMenuSelectContext<string[]>();
131+
const isChecked = Array.isArray(selected) && selected.includes(value);
132+
const handleChange = () => {
133+
const newValue = isChecked ? selected.filter((v) => v !== value) : [...(selected || []), value];
134+
onValueChange(newValue);
135+
};
136+
return (
137+
<BaseDropdownMenuCheckboxItem {...props} checked={isChecked} onCheckedChange={handleChange}>
138+
{children}
139+
</BaseDropdownMenuCheckboxItem>
140+
);
141+
}
142+
143+
/** Radio-select menu item */
144+
export function DropdownMenuSelectRadioItem({
145+
value: itemValue,
146+
children,
147+
...props
148+
}: { value: string; children: React.ReactNode } & React.ComponentProps<typeof BaseDropdownMenuRadioItem>) {
149+
const { onValueChange } = useDropdownMenuSelectContext<string>();
150+
return (
151+
<BaseDropdownMenuRadioItem value={itemValue} {...props} onSelect={() => onValueChange(itemValue)}>
152+
{children}
153+
</BaseDropdownMenuRadioItem>
154+
);
155+
}

0 commit comments

Comments
 (0)