Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature]: Make Select component compatible with React Hook Form #1110

Open
lucafaggianelli opened this issue Aug 7, 2024 · 2 comments
Open
Labels
Type: Accessibility Indicates that the A11y is affected. Type: Enhancement Small enhancement to existing component or feature

Comments

@lucafaggianelli
Copy link
Contributor

What problem does this feature solve?

React Hook Form is a convenient library to handle forms and with native HTML input elements the integration is flawless, actually also with most of Tremor input components the integration is flawless, i.e. with the TextInput I can just do (which is what I would do for the <input> element):

<TextInput
    {...register('company_name')}
    placeholder=""
    required
    minLength={3}
/>

Though the same method doesn't work for the Select component, this is the workaround I used:

const { onChange, ...registerProps } = register('role')

<Select
  {...registerProps}
  placeholder="Role"
  required
  onValueChange={(value) => onChange({ target: { value, name: 'role' } })}
>
  {roles.map((role) => (
    <SelectItem key={role} value={role}>
      {role}
    </SelectItem>
  ))}
</Select>

What does the proposed API look like?

It would be nice to make the Select component compatible with RHF so the integration would be:

<Select
  {...register('role')}
  placeholder="Role"
  required
>
  {roles.map((role) => (
    <SelectItem key={role} value={role}>
      {role}
    </SelectItem>
  ))}
</Select>
@severinlandolt severinlandolt added Type: Enhancement Small enhancement to existing component or feature Type: Accessibility Indicates that the A11y is affected. labels Aug 13, 2024
@angelhodar
Copy link

I already integrated tremor with RHF, here you have the code in case you find it useful. The key is to use the Controller class:

SelectInput.tsx

'use client'

import { Select, SelectItem, SelectProps, SelectItemProps } from '@tremor/react'
import {
  BaseFormInputProps,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from 'components/Primitives/Form'

export interface SelectOptionProps extends SelectItemProps {
  label: string
}

export { Select, SelectItem }

type BaseSelectProps = BaseFormInputProps & Omit<SelectProps, 'children'>

export interface SelectInputProps extends BaseSelectProps {
  options: SelectOptionProps[]
}

export default function SelectInput(props: SelectInputProps) {
  const { name, control, label, description, defaultValue, options, ...rest } =
    props

  return (
    <FormField
      control={control}
      name={name}
      render={({ field }) => (
        <FormItem>
          <FormLabel>{label}</FormLabel>
          <FormControl>
            <Select
              onValueChange={field.onChange}
              defaultValue={field.value?.toString() || ''}
              {...rest}
            >
              {options.map((option) => (
                <SelectItem
                  key={option.value}
                  value={option.value}
                  icon={option.icon}
                >
                  {option.label}
                </SelectItem>
              ))}
            </Select>
          </FormControl>
          <FormDescription>{description}</FormDescription>
          <FormMessage />
        </FormItem>
      )}
    />
  )
}

SearchSelect.tsx

'use client'

import {
  SearchSelect,
  SearchSelectProps as TremorSearchSelectProps,
  SearchSelectItem,
  SearchSelectItemProps,
} from '@tremor/react'
import {
  BaseFormInputProps,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from 'components/Primitives/Form'
import {
  Avatar,
  AvatarImage,
  AvatarFallback,
} from 'components/Primitives/Avatar'

export { SearchSelect, SearchSelectItem }
export type {
  TremorSearchSelectProps as SearchSelectProps,
  SearchSelectItemProps,
}

interface SearchSelectOptionProps extends SearchSelectItemProps {
  label: string
  picture?: string | null
}

type SearchSelectProps = Omit<TremorSearchSelectProps, 'children'> &
  BaseFormInputProps & { enableIconFallback?: boolean }

export interface SearchSelectInputProps extends SearchSelectProps {
  options: SearchSelectOptionProps[]
}

const OptionIcon = ({ option }: { option: SearchSelectOptionProps }) => {
  return (
    <Avatar className="w-7 h-7 mr-4">
      <AvatarImage src={option.picture as string} />
      <AvatarFallback>{option.label}</AvatarFallback>
    </Avatar>
  )
}

export default function SearchSelectInput(props: SearchSelectInputProps) {
  const {
    name,
    control,
    label,
    description,
    placeholder,
    enableIconFallback,
    options,
    onValueChange,
    onSearchValueChange,
    enableClear = true,
  } = props

  const onChange = (
    newValue: string,
    onFormChange: (...event: any[]) => void
  ) => {
    onFormChange(newValue)
    onValueChange?.(newValue)
  }

  return (
    <FormField
      control={control}
      name={name}
      render={({ field }) => (
        <FormItem>
          <FormLabel>{label}</FormLabel>
          <FormControl>
            <SearchSelect
              placeholder={placeholder}
              defaultValue={field.value}
              onValueChange={(value) => onChange(value, field.onChange)}
              onSearchValueChange={onSearchValueChange}
              enableClear={enableClear}
            >
              {options.map((option) => (
                <SearchSelectItem
                  key={option.value}
                  value={option.value}
                  icon={
                    option.picture || enableIconFallback
                      ? () => <OptionIcon option={option} />
                      : undefined
                  }
                >
                  {option.label}
                </SearchSelectItem>
              ))}
            </SearchSelect>
          </FormControl>
          <FormDescription>{description}</FormDescription>
          <FormMessage />
        </FormItem>
      )}
    />
  )
}

MultiSelect.tsx

'use client'

import { useState } from 'react'
import { useFormContext } from 'react-hook-form'
import {
  MultiSelect,
  MultiSelectItem,
  MultiSelectProps as TremorMultiSelectProps,
  MultiSelectItemProps,
} from '@tremor/react'
import {
  BaseFormInputProps,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from 'components/Primitives/Form'

// This is used to control how the multiselect input behaves
export const multiSelectWithAllOptionChangeTransformer =
  (allOptionsValue: string) => (prev: string[], newValues: string[]) => {
    // If there is no selection, reset to all option
    if (newValues.length === 0) return [allOptionsValue]
    // If we have selected the all option while it wasnt selected before, reset to all
    if (!prev.includes(allOptionsValue) && newValues.includes(allOptionsValue))
      return [allOptionsValue]
    // If we had all selected but now there is more selection, then unselect the all option
    if (prev.includes(allOptionsValue) && newValues.length > 1) {
      return newValues.filter((v: string) => v !== allOptionsValue)
    }

    return newValues
  }

interface MultiSelectOptionProps extends MultiSelectItemProps {
  label: string
}

type MultiSelectProps = Omit<TremorMultiSelectProps, 'children'> &
  BaseFormInputProps

export interface MultiSelectInputProps extends MultiSelectProps {
  options: MultiSelectOptionProps[]
  allOptionsValue?: string
}

export default function MultiSelectInput(props: MultiSelectInputProps) {
  const {
    name,
    control,
    label,
    description,
    options,
    allOptionsValue,
    ...rest
  } = props
  const { getValues } = useFormContext()
  const [values, setValues] = useState<string[]>(getValues(name) || [])

  const onSelectionChange = (
    newValues: string[],
    onChange: (...event: any[]) => void
  ) => {
    const newSelection = allOptionsValue
      ? multiSelectWithAllOptionChangeTransformer(allOptionsValue)(
          values,
          newValues
        )
      : newValues
    setValues(newSelection)
    onChange(newSelection)
  }

  return (
    <FormField
      control={control}
      name={name}
      render={({ field }) => (
        <FormItem>
          <FormLabel>{label}</FormLabel>
          <FormControl>
            <MultiSelect
              value={values}
              defaultValue={values}
              placeholderSearch="Buscar..."
              onValueChange={(newValues) =>
                onSelectionChange(newValues, field.onChange)
              }
              {...rest}
            >
              {options.map((option) => (
                <MultiSelectItem key={option.value} value={option.value}>
                  {option.label}
                </MultiSelectItem>
              ))}
            </MultiSelect>
          </FormControl>
          <FormDescription>{description}</FormDescription>
          <FormMessage />
        </FormItem>
      )}
    />
  )
}

@lucafaggianelli
Copy link
Contributor Author

Hey @angelhodar thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Type: Accessibility Indicates that the A11y is affected. Type: Enhancement Small enhancement to existing component or feature
Projects
None yet
Development

No branches or pull requests

3 participants