redwoodjs / rw-shad

Generate components in your RedwoodJS project
MIT License
7 stars 0 forks source link

Idea: support for wrapped Redwood field components #3

Open Benjamin-Lee opened 1 year ago

Benjamin-Lee commented 1 year ago

I'm trying to use shadcn/ui inside a Redwood project while also using Redwood Forms. It would be great if there were a way for the <Input> component from shadcn/ui to wrap the Redwood <TextField> and its siblings. Here's my first pass at it if it's of any help:

import * as React from 'react'

import {
  ButtonField,
  CheckboxField,
  ColorField,
  DateField,
  DatetimeLocalField,
  EmailField,
  FileField,
  HiddenField,
  ImageField,
  MonthField,
  NumberField,
  PasswordField,
  RadioField,
  RangeField,
  ResetField,
  SearchField,
  SubmitField,
  TelField,
  TextField,
  TimeField,
  UrlField,
  WeekField,
} from '@redwoodjs/forms'
import { RegisterOptions } from '@redwoodjs/forms/'

import { cn } from 'src/lib/utils'

export interface InputProps
  extends React.InputHTMLAttributes<HTMLInputElement> {
  validation: RegisterOptions
}

const mapping = {
  button: ButtonField,
  checkbox: CheckboxField,
  color: ColorField,
  date: DateField,
  'datetime-local': DatetimeLocalField,
  email: EmailField,
  file: FileField,
  hidden: HiddenField,
  image: ImageField,
  month: MonthField,
  number: NumberField,
  password: PasswordField,
  radio: RadioField,
  range: RangeField,
  reset: ResetField,
  search: SearchField,
  submit: SubmitField,
  tel: TelField,
  text: TextField,
  time: TimeField,
  url: UrlField,
  week: WeekField,
}

const Input = React.forwardRef<HTMLInputElement, InputProps>(
  ({ className, name, type, ...props }, ref) => {
    const Component = mapping[type] || TextField

    return (
      <Component
        name={name}
        className={cn(
          'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
          className
        )}
        ref={ref}
        {...props}
      />
    )
  }
)
Input.displayName = 'Input'

export { Input }
Tobbe commented 1 year ago

This is great @Benjamin-Lee! Thank you so much for contributing 😀

I've had more people asking for better forms support, but haven't had a chance to look into it yet. And to be honest it might still be a few days/weeks/years until I do.

Benjamin-Lee commented 1 year ago

Happy to help. I ended up changing my strategy for doing this to something so much easier: using cva and just pulling the styles out into an input variant along with some other form-related styles into a file:

import { cva } from "class-variance-authority"

import { cn } from "src/lib/utils"

export const inputVariants = cva(
  "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
)

export const labelVariants = cva(
  "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
  {
    variants: {
      error: {
        true: "text-destructive",
      },
    },
  }
)

export const fieldErrorVariants = cva(
  "block text-[0.8rem] font-medium text-destructive"
)

export const FormItem = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
  return <div ref={ref} className={cn("space-y-2", className)} {...props} />
})
FormItem.displayName = "FormItem"

Then, building a form is as easy as:

        <Form onSubmit={onSubmit} className="space-y-6">
          <div className="flex justify-between">
            <FormItem>
              <Label
                name="email"
                className={labelVariants()}
                errorClassName={labelVariants({ error: true })}
              >
                First Name
              </Label>
              <TextField
                name="firstName"
                className={inputVariants()}
                validation={{
                  required: {
                    value: true,
                    message: 'First name is required',
                  },
                }}
                autoComplete="given-name"
              />
              <FieldError name="firstName" className={fieldErrorVariants()} />
            </FormItem>
            <FormItem>
              <Label
                name="lastName"
                className={labelVariants()}
                errorClassName={labelVariants({ error: true })}
              >
                Last Name
              </Label>
              <TextField
                name="lastName"
                className={inputVariants()}
                validation={{
                  required: {
                    value: true,
                    message: 'Last name is required',
                  },
                }}
                autoComplete="family-name"
              />
              <FieldError name="email" className={fieldErrorVariants()} />
            </FormItem>
          </div>
          <FormItem>
            <Label
              name="email"
              className={labelVariants()}
              errorClassName={labelVariants({ error: true })}
            >
              Email address
            </Label>
            <EmailField
              name="email"
              className={inputVariants()}
              ref={emailRef}
              validation={{
                required: {
                  value: true,
                  message: 'Email is required',
                },
              }}
            />
            <FieldError name="email" className={fieldErrorVariants()} />
          </FormItem>
          <FormItem>
            <Label
              name="password"
              className={labelVariants()}
              errorClassName={labelVariants({ error: true })}
            >
              Password
            </Label>
            <PasswordField
              name="password"
              className={inputVariants()}
              autoComplete="current-password"
              validation={{
                required: {
                  value: true,
                  message: 'Password is required',
                },
                minLength: {
                  value: 8,
                  message: 'Password must be at least 8 characters',
                },
              }}
            />
            <FieldError name="password" className={fieldErrorVariants()} />
          </FormItem>

          <div>
            <Submit className={cn(buttonVariants(), 'w-full')}>Login</Submit>
          </div>
        </Form>
Quelu commented 2 months ago

Hello, I was wondering if you have a solution for the Checkbox and Switch components? Shadcn generates more than just a single component (e.g., a button with an icon inside) for the Checkbox/Switch, so I can't simply apply the class (generated by cva) to Redwood's CheckboxField component. I also tried using Redwood's useRegister, but it doesn't seem to work with the Shadcn component either

Tobbe commented 2 months ago

@Quelu I've just used shad's <Switch /> component directly

<FormField
  control={form.control}
  name="fanControl"
  render={({ field }) => (
    <FormItem className="flex flex-row items-center justify-between">
      <div className="space-y-0.5">
        <FormLabel className="text-base">Fan Control</FormLabel>
        <FormDescription>
          Changes will apply to next refresh cycle
        </FormDescription>
      </div>
      <FormControl>
        <Switch
          checked={field.value}
          onCheckedChange={field.onChange}
        />
      </FormControl>
    </FormItem>
  )}
/>

It works, but you don't get the full RW form integration with its error handling unfortunately

I want a better experience, but haven't prioritized that yet

Quelu commented 2 months ago

Thank you so much for the response!

However, it doesn't work with the Checkbox component when it's set up to have multiple values other than a boolean.

Based on what you provided, I was able to better understand how Shadcn works with react-hook-form. I was then able to create these components, which I can now directly use in my Redwood forms:

export const CheckboxField = ({
  label,
  value,
  name,
}: InputFieldProps & {
  label: React.ReactNode;
  value: string;
}) => {
  return (
    <Controller
      name={name}
      render={({ field }) => (
        <div className="flex gap-4" key={name}>
          <Checkbox
            id={`checkbox-${value}`}
            checked={field.value?.includes(value)}
            onCheckedChange={(checked) => {
              return checked
                ? field.onChange([...(field?.value || []), value])
                : field.onChange(
                    field.value?.filter((newValue) => newValue !== value)
                  );
            }}
          />
          <ShadLabel htmlFor={`checkbox-${value}`}>{label}</ShadLabel>
        </div>
      )}
    />
  );
};

export const CheckboxGroupField = ({
  name,
  options,
  validation,
}: InputFieldProps & {
  options: { label: React.ReactNode; value: string }[];
}) => {
  return (
    <Controller
      name={name}
      rules={validation}
      render={() => (
        <>
          {options.map((option) => (
            <CheckboxField
              key={option.value}
              label={option.label}
              value={option.value}
              name={name}
            />
          ))}
        </>
      )}
    />
  );
};

export const SwitchField = ({
  label,
  name,
  validation,
  defaultValue,
}: InputFieldProps & {
  label?: React.ReactNode;
}) => {
  return (
    <Controller
      name={name}
      rules={validation}
      defaultValue={defaultValue}
      render={({ field }) => (
        <div className="flex gap-4" key={name}>
          <Switch
            id={`switch-${name}`}
            checked={field.value}
            onCheckedChange={field.onChange}
          />
          {label && <ShadLabel htmlFor={`switch-${name}`}>{label}</ShadLabel>}
        </div>
      )}
    />
  );
};