tremorlabs / tremor

React components to build charts and dashboards
https://tremor.so
Apache License 2.0
15.39k stars 446 forks source link

[Feature]: Allow SearchSelect icon to be a custom component #1021

Open angelhodar opened 3 weeks ago

angelhodar commented 3 weeks ago

What problem does this feature solve?

Hey everyone, I hope to find all of you well! I have been testing new inputs and changes in tremor and I have a suggestion. Right now, I have added a SearchSelect where I render a list of clients. Each client has a logo for their brand, but its an external image url, so I cant render a static component as it needs the url prop to be passed. To solve this, I had to do something like this in the SearchSelectInput wrapper I am creating:

'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 } from 'components/Primitives/Avatar'

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

type SearchSelectProps = Omit<TremorSearchSelectProps, 'children'> &
  BaseFormInputProps

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

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

  return (
    <FormField
      control={control}
      name={name}
      render={({ field }) => (
        <FormItem>
          <FormLabel>{label}</FormLabel>
          <FormControl>
            <SearchSelect
              placeholder={placeholder}
              defaultValue={field.value}
              onValueChange={field.onChange}
              onSearchValueChange={onSearchValueChange}
              enableClear={enableClear}
            >
              {options.map((option) => (
                <SearchSelectItem key={option.value} value={option.value}>
                  <div className="flex flex-row items-center">
                    {option.picture && (
                      <Avatar className="w-7 h-7 mr-4">
                        <AvatarImage src={option.picture} />
                      </Avatar>
                    )}
                    {option.label}
                  </div>
                </SearchSelectItem>
              ))}
            </SearchSelect>
          </FormControl>
          <FormDescription>{description}</FormDescription>
          <FormMessage />
        </FormItem>
      )}
    />
  )
}

Its working BUT the browser gives a warning because a div cant be a child of select option:

image

What does the proposed API look like?

To solve this, I have thought about changing the icon signature to accept a React.ReactNode too, and in the render just check if the icon is a function or not, so it would allow to pass custom components. It would be something like this:

import React from 'react'
import { Combobox } from '@headlessui/react'
import { makeClassName, tremorTwMerge } from 'lib'

const makeSearchSelectItemClassName = makeClassName('SearchSelectItem')

export interface SearchSelectItemProps
  extends React.HTMLAttributes<HTMLLIElement> {
  value: string
  icon?: React.ElementType | React.ReactNode
}

const SearchSelectItem = React.forwardRef<HTMLLIElement, SearchSelectItemProps>(
  (props, ref) => {
    const { value, icon, className, children, ...other } = props

    // Render the icon if it's a component type (function) or a valid React element
    const renderIcon = () => {
      if (typeof icon === 'function') {
        const iconClassName = () =>
          tremorTwMerge(
            makeSearchSelectItemClassName('icon'),
            'flex-none h-5 w-5 mr-3 text-tremor-content-subtle dark:text-dark-tremor-content-subtle'
          )
        const IconComponent = icon
        return <IconComponent className={iconClassName()} />
      } else if (React.isValidElement(icon)) {
        return icon
      }
      return null
    }

    return (
      <Combobox.Option
        className={tremorTwMerge(
          makeSearchSelectItemClassName('root'),
          'flex justify-start items-center cursor-default text-tremor-default p-2.5 ui-active:bg-tremor-background-muted  ui-active:text-tremor-content-strong ui-selected:text-tremor-content-strong ui-selected:bg-tremor-background-muted text-tremor-content-emphasis dark:ui-active:bg-dark-tremor-background-muted  dark:ui-active:text-dark-tremor-content-strong dark:ui-selected:text-dark-tremor-content-strong dark:ui-selected:bg-dark-tremor-background-muted dark:text-dark-tremor-content-emphasis',
          className
        )}
        ref={ref}
        key={value}
        value={value}
        {...other}
      >
        {renderIcon()}
        <span className="whitespace-nowrap truncate">{children ?? value}</span>
      </Combobox.Option>
    )
  }
)

SearchSelectItem.displayName = 'SearchSelectItem'

export default SearchSelectItem

What do you think? Btw if you just want to keep this simple and allow this custom functionality in the new raw components you are creating, i totally understand it :)