radix-ui / primitives

Radix Primitives is an open-source UI component library for building high-quality, accessible design systems and web apps. Maintained by @workos.
https://radix-ui.com/primitives
MIT License
15.35k stars 765 forks source link

[Select] - Changing an Uncontrolled select doesn't trigger a form validation change #2485

Closed norayr93 closed 10 months ago

norayr93 commented 10 months ago

Bug report

Current Behavior

I have a Remix form in combination with @conform-to/zod and @conform-to/react. I am implementing client-side validation. I am using the first example in your docs

import { z } from 'zod';
import { Form, useActionData } from '@remix-run/react';
import { type DataFunctionArgs, json, type MetaFunction } from '@remix-run/node';
import { conform, useForm } from '@conform-to/react';
import { getFieldsetConstraint, parse } from '@conform-to/zod';

import { Field } from '#app/components/core/form/field.tsx';
import { Header } from '#app/components/ui/header/index.tsx';
import { Content } from '#app/components/ui/content/index.tsx';
import { Container } from '#app/components/ui/container/index.tsx';
import { Button } from '#app/components/core/button/index.tsx';
import SelectDemo from '#app/components/core/form/select-demo.tsx';

const schema = z.object({
    name: z.string({ required_error: 'Name is required' }).min(3, 'Min length is 3'),
    username: z.string({ required_error: 'Username is required' }).min(3, 'Min length is 3'),
    select_pets: z.string({ required_error: 'Pets are required' }),
    select_demo: z.string({ required_error: 'Select demo is required' }),
    select: z.string({ required_error: 'Select is required' }),
    radio: z.string({ required_error: 'Radio is required' }),
    date: z.date({ required_error: 'Date is required' }),
});

export const meta: MetaFunction = () => {
    return [{ title: '' }, { name: 'description', content: 'Buy now pay later, powered by GrailPay!' }];
};

export async function action({ request }: DataFunctionArgs) {
    const formData = await request.formData();
    const submission = parse(formData, { schema });
    console.log(submission, 'submission');
    if (!submission.value || submission.intent !== 'submit') {
        return json(submission);
    }
}

export default function Index() {
    const actionData = useActionData<typeof action>();

    const schema = z.object({
        name: z.string({ required_error: 'Name is required' }).min(3, 'Min length is 3'),
        username: z.string({ required_error: 'Username is required' }).min(3, 'Min length is 3'),
        select_pets: z.string({ required_error: 'Pets are required' }),
        select_demo: z.string({ required_error: 'Select demo is required' }),
        select: z.string({ required_error: 'Select is required' }),
        radio: z.string({ required_error: 'Radio is required' }),
        date: z.date({ required_error: 'Date is required' }),
    });

    const [form, fields] = useForm({
        noValidate: false,
        id: 'login-form',
        lastSubmission: actionData,
        constraint: getFieldsetConstraint(schema),
        onValidate({ formData }) {
            return parse(formData, { schema });
        },
        shouldRevalidate: 'onInput',
    });

    return (
        <Container>
            <Header title="Hello world" allowBack showLogo />
            <Content>
                <Form method="POST" {...form.props}>
                    <Field
                        labelProps={{ children: 'Name' }}
                        inputProps={{
                            placeholder: 'Your name...',
                            ...conform.input(fields.name),
                        }}
                        errors={fields.name.errors}
                    />
                    <div className="mb-8 flex flex-col ">
                        <label htmlFor="pet-select">Choose a pet:</label>
                        <select className="min-h-[40px] border" id="pet-select" {...conform.input(fields.select_pets)}>
                            <option value="">--Please choose an option--</option>
                            <option value="dog">Dog</option>
                            <option value="cat">Cat</option>
                            <option value="hamster">Hamster</option>
                            <option value="parrot">Parrot</option>
                            <option value="spider">Spider</option>
                            <option value="goldfish">Goldfish</option>
                        </select>
                        <div>{fields.select_pets.errors?.map(err => <span key={err}>{err}</span>)}</div>
                    </div>
                    <div className="mb-8 flex flex-col ">
                        <SelectDemo {...conform.input(fields.select_demo)} />
                        <div>{fields.select_demo.errors?.map(err => <span key={err}>{err}</span>)}</div>
                    </div>
                    <Button type="submit">Submit</Button>
                </Form>
            </Content>
        </Container>
    );
}
import React from 'react';
import * as Select from '@radix-ui/react-select';
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons';

const SelectItem = React.forwardRef<any, any>(({ children, className, ...props }, forwardedRef) => {
    return (
        <Select.Item className="SelectItem" {...props} ref={forwardedRef}>
            <Select.ItemText>{children}</Select.ItemText>
            <Select.ItemIndicator className="SelectItemIndicator">
                <CheckIcon />
            </Select.ItemIndicator>
        </Select.Item>
    );
});

SelectItem.displayName = 'Select Item';

const SelectDemo = (props: any) => (
    <Select.Root {...props}>
        <Select.Trigger className="SelectTrigger" aria-label="Food">
            <Select.Value placeholder="Select a fruit…" />
            <Select.Icon className="SelectIcon">
                <ChevronDownIcon />
            </Select.Icon>
        </Select.Trigger>
        <Select.Portal>
            <Select.Content className="SelectContent">
                <Select.ScrollUpButton className="SelectScrollButton">
                    <ChevronUpIcon />
                </Select.ScrollUpButton>
                <Select.Viewport className="SelectViewport">
                    <Select.Group>
                        <Select.Label className="SelectLabel">Fruits</Select.Label>
                        <SelectItem value="apple">Apple</SelectItem>
                        <SelectItem value="banana">Banana</SelectItem>
                        <SelectItem value="blueberry">Blueberry</SelectItem>
                        <SelectItem value="grapes">Grapes</SelectItem>
                        <SelectItem value="pineapple">Pineapple</SelectItem>
                    </Select.Group>

                    <Select.Separator className="SelectSeparator" />

                    <Select.Group>
                        <Select.Label className="SelectLabel">Vegetables</Select.Label>
                        <SelectItem value="aubergine">Aubergine</SelectItem>
                        <SelectItem value="broccoli">Broccoli</SelectItem>
                        <SelectItem value="carrot" disabled>
                            Carrot
                        </SelectItem>
                        <SelectItem value="courgette">Courgette</SelectItem>
                        <SelectItem value="leek">Leek</SelectItem>
                    </Select.Group>

                    <Select.Separator className="SelectSeparator" />

                    <Select.Group>
                        <Select.Label className="SelectLabel">Meat</Select.Label>
                        <SelectItem value="beef">Beef</SelectItem>
                        <SelectItem value="chicken">Chicken</SelectItem>
                        <SelectItem value="lamb">Lamb</SelectItem>
                        <SelectItem value="pork">Pork</SelectItem>
                    </Select.Group>
                </Select.Viewport>
                <Select.ScrollDownButton className="SelectScrollButton">
                    <ChevronDownIcon />
                </Select.ScrollDownButton>
            </Select.Content>
        </Select.Portal>
    </Select.Root>
);

export default SelectDemo;

When I first submit the form, it properly shows the validation error for the Select field. After that, once I start to change the Select and pick one of the options, the validation error is still there as no event is triggered and no re-render happens. The value on the Select is changed, but only internally and not handling the internal select with aria-hidden={true}.

Expected behavior

In the docs, it's clearly mentioned it has a support of Uncontrolled component. The expected behavior is that whenever we change the Select and pick an option ( In this case the required constraint is satisfied ), the form should get that value, and revalidation should happen and remove the error message.

FYI - html select works as expected.

Software Name(s) Version
Radix Package(s) @radix-ui/react-select "^2.0.0"
React "react" "^18.2.0"
Browser Chrome
Node 18.6.0
npm 9.5.1
Operating System Macos 13.3.1 (22E261)
ng-hai commented 10 months ago

I think because of the Select.Root API is onValueChange, not onChange so you have to manually handle it instead of spreading props from the validation library

norayr93 commented 10 months ago

@ng-hai Then how is it supposed to be uncontrolled?

kotAPI commented 10 months ago

how did you fix this? @norayr93

ng-hai commented 10 months ago

This is how I use with react-hook-form

image