Skip to content

Commit b2721d7

Browse files
committed
Fix: add selectRadixOption helper and stabilize story re-renders
- Create selectRadixOption helper to handle Portals and timing\n- Memoize useRemixForm methods in stories to prevent tree remounts\n- Fix missing imports and types in use-on-form-value-change.stories.tsx
1 parent 1989a94 commit b2721d7

2 files changed

Lines changed: 92 additions & 36 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { userEvent, within, screen, waitFor } from '@storybook/test';
2+
3+
/**
4+
* A robust helper to select an option from a Radix-based Select/Combobox.
5+
* Handles portals, animations, and pointer-event blockers.
6+
*/
7+
export async function selectRadixOption(
8+
canvasElement: HTMLElement,
9+
options: {
10+
triggerRole?: 'combobox' | 'button';
11+
triggerName: string | RegExp;
12+
optionName: string | RegExp;
13+
optionTestId?: string;
14+
},
15+
) {
16+
const canvas = within(canvasElement);
17+
const { triggerRole = 'combobox', triggerName, optionName, optionTestId } = options;
18+
19+
// 1. Find and click the trigger within the component canvas
20+
const trigger = await canvas.findByRole(triggerRole, { name: triggerName });
21+
await userEvent.click(trigger);
22+
23+
// 2. Wait for the listbox to appear in the document body (Portal)
24+
// We use findByRole on screen to wait for the element to mount.
25+
await screen.findByRole('listbox');
26+
27+
// 3. Find the option
28+
// Scoping the search to document.body ensures we find the portal content.
29+
let option: HTMLElement;
30+
if (optionTestId) {
31+
option = await screen.findByTestId(optionTestId);
32+
} else {
33+
option = await screen.findByRole('option', { name: optionName });
34+
}
35+
36+
// 4. Click the option
37+
// pointerEventsCheck: 0 is used to bypass Radix's temporary pointer-event locks during animations
38+
await userEvent.click(option, { pointerEventsCheck: 0 });
39+
40+
// 5. Verify the dropdown closed (optional but ensures stability)
41+
await waitFor(() => {
42+
const listbox = screen.queryByRole('listbox');
43+
if (listbox) throw new Error('Listbox still visible');
44+
});
45+
}

apps/docs/src/remix-hook-form/use-on-form-value-change.stories.tsx

Lines changed: 47 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import { Select } from '@lambdacurry/forms/remix-hook-form/select';
55
import { TextField } from '@lambdacurry/forms/remix-hook-form/text-field';
66
import type { Meta, StoryContext, StoryObj } from '@storybook/react-vite';
77
import { expect, userEvent, within, screen, waitFor } from '@storybook/test';
8-
import { useState } from 'react';
9-
import { type ActionFunctionArgs, useFetcher } from 'react-router';
10-
import { getValidatedFormData, RemixFormProvider, useRemixForm } from 'remix-hook-form';
8+
import { useState, useMemo } from 'react';
9+
import { useFetcher } from 'react-router';
10+
import { useRemixForm, RemixFormProvider, getValidatedFormData } from 'remix-hook-form';
1111
import { z } from 'zod';
12+
import type { ActionFunctionArgs } from 'react-router';
13+
import { selectRadixOption } from '../lib/storybook/test-utils';
1214
import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub';
1315

1416
/**
@@ -156,22 +158,24 @@ export const CascadingDropdowns: Story = {
156158
play: async ({ canvasElement }: StoryContext) => {
157159
const canvas = within(canvasElement);
158160

159-
// Select a country
160-
const countryTrigger = canvas.getByRole('combobox', { name: /country/i });
161-
await userEvent.click(countryTrigger);
162-
163-
// Wait for dropdown to open and select USA
164-
const usaOption = await screen.findByTestId('select-option-usa');
165-
await userEvent.click(usaOption);
166-
167-
// Verify state dropdown is now enabled
168-
const stateTrigger = canvas.getByRole('combobox', { name: /state/i });
169-
expect(stateTrigger).not.toBeDisabled();
170-
171-
// Select a state
172-
await userEvent.click(stateTrigger);
173-
const californiaOption = await screen.findByTestId('select-option-california');
174-
await userEvent.click(californiaOption);
161+
// Select USA
162+
await selectRadixOption(canvasElement, {
163+
triggerName: /country/i,
164+
optionName: /united states/i,
165+
optionTestId: 'select-option-usa',
166+
});
167+
168+
// Select a state (wait for it to be enabled)
169+
await waitFor(() => {
170+
const stateTrigger = canvas.getByRole('combobox', { name: /state/i });
171+
expect(stateTrigger).not.toBeDisabled();
172+
});
173+
174+
await selectRadixOption(canvasElement, {
175+
triggerName: /state/i,
176+
optionName: /california/i,
177+
optionTestId: 'select-option-california',
178+
});
175179

176180
// Enter city
177181
const cityInput = canvas.getByLabelText(/city/i);
@@ -213,7 +217,7 @@ type OrderFormData = z.infer<typeof orderSchema>;
213217
const AutoCalculationExample = () => {
214218
const fetcher = useFetcher<{ message: string }>();
215219

216-
const methods = useRemixForm<OrderFormData>({
220+
const rawMethods = useRemixForm<OrderFormData>({
217221
resolver: zodResolver(orderSchema),
218222
defaultValues: {
219223
quantity: '1',
@@ -228,6 +232,10 @@ const AutoCalculationExample = () => {
228232
},
229233
});
230234

235+
// Memoize methods to prevent unnecessary re-renders of the story tree
236+
// which can disrupt interaction tests using Portals
237+
const methods = useMemo(() => rawMethods, [rawMethods]);
238+
231239
const calculateTotal = () => {
232240
const quantity = Number.parseFloat(methods.getValues('quantity') || '0');
233241
const pricePerUnit = Number.parseFloat(methods.getValues('pricePerUnit') || '0');
@@ -382,7 +390,7 @@ const ConditionalFieldsExample = () => {
382390
const [showShipping, setShowShipping] = useState(false);
383391
const [showPickup, setShowPickup] = useState(false);
384392

385-
const methods = useRemixForm<ShippingFormData>({
393+
const rawMethods = useRemixForm<ShippingFormData>({
386394
resolver: zodResolver(shippingSchema),
387395
defaultValues: {
388396
deliveryType: '',
@@ -396,6 +404,9 @@ const ConditionalFieldsExample = () => {
396404
},
397405
});
398406

407+
// Memoize methods to prevent unnecessary re-renders of the story tree
408+
const methods = useMemo(() => rawMethods, [rawMethods]);
409+
399410
// Show/hide fields based on delivery type
400411
useOnFormValueChange({
401412
name: 'deliveryType',
@@ -482,34 +493,34 @@ export const ConditionalFields: Story = {
482493
const canvas = within(canvasElement);
483494

484495
// Select delivery
485-
await new Promise((resolve) => setTimeout(resolve, 500));
486-
const deliveryTypeTrigger = canvas.getByRole('combobox', { name: /delivery type/i });
487-
await userEvent.click(deliveryTypeTrigger);
488-
489-
const deliveryOption = await screen.findByTestId('select-option-delivery');
490-
await userEvent.click(deliveryOption);
496+
await selectRadixOption(canvasElement, {
497+
triggerName: /delivery type/i,
498+
optionName: /home delivery/i,
499+
optionTestId: 'select-option-delivery',
500+
});
491501

492502
// Shipping address field should appear
493503
const shippingInput = await canvas.findByLabelText(/shipping address/i);
494504
expect(shippingInput).toBeInTheDocument();
495505
await userEvent.type(shippingInput, '123 Main St');
496506

497507
// Switch to pickup
498-
await new Promise((resolve) => setTimeout(resolve, 2000));
499-
const trigger = await canvas.findByRole('combobox', { name: /delivery type/i });
500-
await userEvent.click(trigger);
501-
502-
const pickupOption = await screen.findByTestId('select-option-pickup', {}, { timeout: 5000 });
503-
await userEvent.click(pickupOption);
508+
await selectRadixOption(canvasElement, {
509+
triggerName: /delivery type/i,
510+
optionName: /store pickup/i,
511+
optionTestId: 'select-option-pickup',
512+
});
504513

505514
// Store location should appear, shipping address should be gone
506515
const storeSelect = await canvas.findByRole('combobox', { name: /store location/i });
507516
expect(storeSelect).toBeInTheDocument();
508517

509518
// Select a store
510-
await userEvent.click(storeSelect);
511-
const mallOption = await screen.findByTestId('select-option-mall');
512-
await userEvent.click(mallOption);
519+
await selectRadixOption(canvasElement, {
520+
triggerName: /store location/i,
521+
optionName: /shopping mall/i,
522+
optionTestId: 'select-option-mall',
523+
});
513524

514525
// Submit form
515526
const submitButton = canvas.getByRole('button', { name: /complete order/i });

0 commit comments

Comments
 (0)