-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathselect-custom.stories.tsx
More file actions
238 lines (210 loc) · 7.46 KB
/
select-custom.stories.tsx
File metadata and controls
238 lines (210 loc) · 7.46 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
import { zodResolver } from '@hookform/resolvers/zod';
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 { getValidatedFormData, RemixFormProvider, useRemixForm } from 'remix-hook-form';
import { z } from 'zod';
import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub';
const formSchema = z.object({
theme: z.string().min(1, 'Please select a theme'),
fruit: z.string().min(1, 'Please select a fruit'),
});
type FormData = z.infer<typeof formSchema>;
const themeOptions = [
{ label: 'Default', value: 'default' },
{ label: 'Purple', value: 'purple' },
{ label: 'Green', value: 'green' },
];
const fruitOptions = [
{ label: '🍎 Apple', value: 'apple' },
{ label: '🍊 Orange', value: 'orange' },
{ label: '🍌 Banana', value: 'banana' },
{ label: '🍇 Grape', value: 'grape' },
];
// Custom Trigger (purple themed)
const PurpleTrigger = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
(props, ref) => (
<button
ref={ref}
type="button"
{...props}
className={clsx(
'flex items-center justify-between w-full rounded-md border-2 border-purple-300 bg-purple-50 px-3 py-2 h-10 text-sm text-purple-900 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2',
props.className,
)}
/>
),
);
PurpleTrigger.displayName = 'PurpleTrigger';
// Custom Item (purple themed)
const PurpleItem = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement> & { selected?: boolean }
>((props, ref) => (
<button
ref={ref}
type="button"
{...props}
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';
// Custom Search Input (purple themed)
const PurpleSearchInput = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
(props, ref) => (
<input
ref={ref}
{...props}
className={clsx(
'w-full h-9 rounded-md bg-white px-2 text-sm leading-none border-2 border-purple-200 focus:border-purple-400 focus:outline-none',
props.className,
)}
/>
),
);
PurpleSearchInput.displayName = 'PurpleSearchInput';
// Custom Item (green themed) for the fruit example
const GreenItem = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement> & { selected?: boolean }
>((props, ref) => (
<button
ref={ref}
type="button"
{...props}
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';
const SelectCustomizationExample = () => {
const fetcher = useFetcher<{ message: string }>();
const methods = useRemixForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
theme: '',
fruit: '',
},
fetcher,
submitConfig: { action: '/', method: 'post' },
});
return (
<RemixFormProvider {...methods}>
<fetcher.Form onSubmit={methods.handleSubmit} className="space-y-6">
<div className="grid gap-6 w-[320px]">
{/* Custom Trigger and Item using components */}
<Select
name="theme"
label="Theme"
description="Customized trigger and options via components"
options={themeOptions}
placeholder="Choose a theme"
components={{
Trigger: PurpleTrigger,
Item: PurpleItem,
SearchInput: PurpleSearchInput,
}}
/>
{/* Fun labels with emojis to show arbitrary option labels */}
<Select
name="fruit"
label="Favorite Fruit"
description="Options can include emojis or rich labels"
options={fruitOptions}
placeholder="Pick a fruit"
components={{
Item: GreenItem,
}}
/>
</div>
<Button type="submit">Submit</Button>
{fetcher.data?.message && <p className="mt-2 text-green-600">{fetcher.data.message}</p>}
</fetcher.Form>
</RemixFormProvider>
);
};
const handleFormSubmission = async (request: Request) => {
const { errors } = await getValidatedFormData<FormData>(request, zodResolver(formSchema));
if (errors) return { errors };
return { message: 'Form submitted successfully' };
};
const meta: Meta<typeof Select> = {
title: 'RemixHookForm/Select Customized',
component: Select,
parameters: { layout: 'centered' },
tags: ['autodocs'],
decorators: [
withReactRouterStubDecorator({
routes: [
{
path: '/',
Component: SelectCustomizationExample,
action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request),
},
],
}),
],
} satisfies Meta<typeof Select>;
export default meta;
type Story = StoryObj<typeof meta>;
export const CustomComponents: Story = {
parameters: {
docs: {
description: {
story: `
### Select Component Customization
This story demonstrates customizing the Select component by passing component overrides, similar to TextField:
- Override the trigger button via \`components.Trigger\`
- Customize option items with \`components.Item\` (receives \`selected\` and ARIA roles)
- Replace the search input with \`components.SearchInput\`
Example:
\`\`\`tsx
<Select
name="theme"
label="Theme"
options={themeOptions}
components={{
Trigger: PurpleTrigger,
Item: PurpleItem,
SearchInput: PurpleSearchInput,
}}
/>
\`\`\`
Each custom component should use React.forwardRef to preserve focus, ARIA, and keyboard behavior.
`,
},
},
},
render: () => <SelectCustomizationExample />,
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
await step('Open and choose Theme', async () => {
const themeSelect = canvas.getByLabelText('Theme');
await userEvent.click(themeSelect);
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 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 () => {
const submitButton = canvas.getByRole('button', { name: 'Submit' });
await userEvent.click(submitButton);
await expect(canvas.findByText('Form submitted successfully')).resolves.toBeInTheDocument();
});
},
};