Skip to content

A shadcn/ui style fast, composable, fully-featured multiple selector with a textarea for React, inspired by the Multiple Selector in shadcnui-expansions.

Notifications You must be signed in to change notification settings

nidhiyashwanth/shadcn-ui-extensions

Repository files navigation

Multiple Textarea Selector

A textarea-based version of the Multiple Selector component, allowing for multi-line text input while maintaining all the functionality of the original selector.

Features

  • All features from Multiple Selector
  • Auto-resizing textarea with configurable min/max height
  • Maintains badge height consistency with max-h-[22px] to prevent stretching with textarea
  • Async search with debounce
  • Creatable selector — create options when there is no match
  • Grouping functionality
  • Working with react-hook-form
  • Customizable loading spinner and empty indicator
  • Fixed options, maximum selected count
  • Ability to disable default first item selection

Installation

  1. First, install the required shadcn-ui components:
npx shadcn-ui@latest add command badge
  1. Then, paste code from MultipleTextAreaSelector component, alternatively you can use MultipleSelectorCreatable component inside src/components/ui/common/MultipleSelectorCreatable.tsx:
'use client'

import * as React from 'react'
import { X } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import {
  Command,
  CommandGroup,
  CommandItem,
  CommandList
} from '@/components/ui/command'
import { cn } from '@/lib/utils'
import { Command as CommandPrimitive, useCommandState } from 'cmdk'

export interface Option {
  value: string
  label: string
  disable?: boolean
  /** fixed option that can't be removed. */
  fixed?: boolean
  /** Group the options by providing key. */
  [key: string]: string | boolean | undefined
}

interface GroupOption {
  [key: string]: Option[]
}

interface MultipleTextAreaSelectorProps {
  value?: Option[]
  defaultOptions?: Option[]
  /** manually controlled options */
  options?: Option[]
  placeholder?: string
  /** Loading component. */
  loadingIndicator?: React.ReactNode
  /** Empty component. */
  emptyIndicator?: React.ReactNode
  /** Debounce time for async search. Only work with `onSearch`. */
  delay?: number
  /**
   * Only work with `onSearch` prop. Trigger search when `onFocus`.
   * For example, when user click on the input, it will trigger the search to get initial options.
   **/
  triggerSearchOnFocus?: boolean
  /** async search */
  onSearch?: (value: string) => Promise<Option[]>
  /**
   * sync search. This search will not showing loadingIndicator.
   * The rest props are the same as async search.
   * i.e.: creatable, groupBy, delay.
   **/
  onSearchSync?: (value: string) => Option[]
  onChange?: (options: Option[]) => void
  /** Limit the maximum number of selected options. */
  maxSelected?: number
  /** When the number of selected options exceeds the limit, the onMaxSelected will be called. */
  onMaxSelected?: (maxLimit: number) => void
  /** Hide the placeholder when there are options selected. */
  hidePlaceholderWhenSelected?: boolean
  disabled?: boolean
  /** Group the options base on provided key. */
  groupBy?: string
  className?: string
  badgeClassName?: string
  /**
   * First item selected is a default behavior by cmdk. That is why the default is true.
   * This is a workaround solution by add a dummy item.
   *
   * @reference: https://github.com/pacocoursey/cmdk/issues/171
   */
  selectFirstItem?: boolean
  /** Allow user to create option when there is no option matched. */
  creatable?: boolean
  /** Props of `Command` */
  commandProps?: React.ComponentPropsWithoutRef<typeof Command>
  /** Props of `textarea` */
  textareaProps?: Omit<
    React.TextareaHTMLAttributes<HTMLTextAreaElement>,
    'value' | 'placeholder' | 'disabled'
  >
  /** Minimum height of textarea */
  minHeight?: number
  /** Maximum height of textarea */
  maxHeight?: number
  /** hide the clear all button. */
  hideClearAllButton?: boolean
}

export interface MultipleTextAreaSelectorRef {
  selectedValue: Option[]
  textarea: HTMLTextAreaElement
  focus: () => void
  reset: () => void
}

// Reuse the utility functions from MultipleSelector
function transToGroupOption(options: Option[], groupBy?: string) {
  if (options.length === 0) {
    return {}
  }
  if (!groupBy) {
    return {
      '': options
    }
  }

  const groupOption: GroupOption = {}
  options.forEach(option => {
    const key = (option[groupBy] as string) || ''
    if (!groupOption[key]) {
      groupOption[key] = []
    }
    groupOption[key].push(option)
  })
  return groupOption
}

function removePickedOption(groupOption: GroupOption, picked: Option[]) {
  const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption

  for (const [key, value] of Object.entries(cloneOption)) {
    cloneOption[key] = value.filter(
      val => !picked.find(p => p.value === val.value)
    )
  }
  return cloneOption
}

function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {
  for (const [, value] of Object.entries(groupOption)) {
    if (
      value.some(option => targetOption.find(p => p.value === option.value))
    ) {
      return true
    }
  }
  return false
}

export function useDebounce<T>(value: T, delay?: number): T {
  const [debouncedValue, setDebouncedValue] = React.useState<T>(value)

  React.useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay || 500)

    return () => {
      clearTimeout(timer)
    }
  }, [value, delay])

  return debouncedValue
}

const CommandEmpty = React.forwardRef<
  HTMLDivElement,
  React.ComponentProps<typeof CommandPrimitive.Empty>
>(({ className, ...props }, forwardedRef) => {
  const render = useCommandState(state => state.filtered.count === 0)

  if (!render) return null

  return (
    <div
      ref={forwardedRef}
      className={cn('py-6 text-center text-sm', className)}
      cmdk-empty=''
      role='presentation'
      {...props}
    />
  )
})

CommandEmpty.displayName = 'CommandEmpty'

const MultipleTextAreaSelector = React.forwardRef<
  MultipleTextAreaSelectorRef,
  MultipleTextAreaSelectorProps
>(
  (
    {
      value,
      onChange,
      placeholder,
      defaultOptions: arrayDefaultOptions = [],
      options: arrayOptions,
      delay,
      onSearch,
      onSearchSync,
      loadingIndicator,
      emptyIndicator,
      maxSelected = Number.MAX_SAFE_INTEGER,
      onMaxSelected,
      hidePlaceholderWhenSelected,
      disabled,
      groupBy,
      className,
      badgeClassName,
      selectFirstItem = true,
      creatable = false,
      triggerSearchOnFocus = false,
      commandProps,
      textareaProps,
      minHeight = 52,
      maxHeight = 200,
    }: MultipleTextAreaSelectorProps,
    ref: React.Ref<MultipleTextAreaSelectorRef>
  ) => {
    const textareaRef = React.useRef<HTMLTextAreaElement>(null)
    const [open, setOpen] = React.useState(false)
    const [onScrollbar, setOnScrollbar] = React.useState(false)
    const [isLoading, setIsLoading] = React.useState(false)
    const dropdownRef = React.useRef<HTMLDivElement>(null)

    const [selected, setSelected] = React.useState<Option[]>(value || [])
    const [options, setOptions] = React.useState<GroupOption>(
      transToGroupOption(arrayDefaultOptions, groupBy)
    )
    const [inputValue, setInputValue] = React.useState('')
    const debouncedSearchTerm = useDebounce(inputValue, delay || 500)

    // Add autosize functionality
    React.useEffect(() => {
      const textarea = textareaRef.current
      if (!textarea) return

      const setHeight = () => {
        textarea.style.height = 'auto'
        const scrollHeight = textarea.scrollHeight
        textarea.style.height = `${Math.min(
          Math.max(scrollHeight, minHeight),
          maxHeight
        )}px`
      }

      setHeight()
      textarea.addEventListener('input', setHeight)
      return () => textarea.removeEventListener('input', setHeight)
    }, [maxHeight, minHeight])

    React.useImperativeHandle(
      ref,
      () => ({
        selectedValue: [...selected],
        textarea: textareaRef.current as HTMLTextAreaElement,
        focus: () => textareaRef?.current?.focus(),
        reset: () => setSelected([])
      }),
      [selected]
    )

    const handleClickOutside = (event: MouseEvent | TouchEvent) => {
      if (
        dropdownRef.current &&
        !dropdownRef.current.contains(event.target as Node) &&
        textareaRef.current &&
        !textareaRef.current.contains(event.target as Node)
      ) {
        setOpen(false)
        textareaRef.current.blur()
      }
    }

    const handleUnselect = React.useCallback(
      (option: Option) => {
        const newOptions = selected.filter(s => s.value !== option.value)
        setSelected(newOptions)
        onChange?.(newOptions)
      },
      [onChange, selected]
    )

    const handleKeyDown = React.useCallback(
      (e: React.KeyboardEvent<HTMLDivElement>) => {
        const textarea = textareaRef.current
        if (textarea) {
          if (e.key === 'Delete' || e.key === 'Backspace') {
            if (textarea.value === '' && selected.length > 0) {
              const lastSelectOption = selected[selected.length - 1]
              if (!lastSelectOption.fixed) {
                handleUnselect(selected[selected.length - 1])
              }
            }
          }
          if (e.key === 'Enter' && !e.shiftKey) {
            e.preventDefault()

            if (inputValue && creatable) {
              if (
                !isOptionsExist(options, [
                  { value: inputValue, label: inputValue }
                ]) &&
                !selected.find(s => s.value === inputValue)
              ) {
                if (selected.length >= maxSelected) {
                  onMaxSelected?.(selected.length)
                  return
                }
                const newOptions = [
                  ...selected,
                  { value: inputValue, label: inputValue }
                ]
                setSelected(newOptions)
                onChange?.(newOptions)
                setInputValue('')
              }
            }
          }
          if (e.key === 'Escape') {
            textarea.blur()
          }
        }
      },
      [
        handleUnselect,
        selected,
        inputValue,
        creatable,
        options,
        maxSelected,
        onMaxSelected,
        onChange
      ]
    )

    React.useEffect(() => {
      if (open) {
        document.addEventListener('mousedown', handleClickOutside)
        document.addEventListener('touchend', handleClickOutside)
      } else {
        document.removeEventListener('mousedown', handleClickOutside)
        document.removeEventListener('touchend', handleClickOutside)
      }

      return () => {
        document.removeEventListener('mousedown', handleClickOutside)
        document.removeEventListener('touchend', handleClickOutside)
      }
    }, [open])

    React.useEffect(() => {
      if (value) {
        setSelected(value)
      }
    }, [value])

    React.useEffect(() => {
      if (!arrayOptions || onSearch) {
        return
      }
      const newOption = transToGroupOption(arrayOptions || [], groupBy)
      if (JSON.stringify(newOption) !== JSON.stringify(options)) {
        setOptions(newOption)
      }
    }, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options])

    React.useEffect(() => {
      const doSearchSync = () => {
        const res = onSearchSync?.(debouncedSearchTerm)
        setOptions(transToGroupOption(res || [], groupBy))
      }

      const exec = async () => {
        if (!onSearchSync || !open) return

        if (triggerSearchOnFocus) {
          doSearchSync()
        }

        if (debouncedSearchTerm) {
          doSearchSync()
        }
      }

      void exec()
    }, [debouncedSearchTerm, groupBy, onSearchSync, open, triggerSearchOnFocus])

    React.useEffect(() => {
      const doSearch = async () => {
        setIsLoading(true)
        const res = await onSearch?.(debouncedSearchTerm)
        setOptions(transToGroupOption(res || [], groupBy))
        setIsLoading(false)
      }

      const exec = async () => {
        if (!onSearch || !open) return

        if (triggerSearchOnFocus) {
          await doSearch()
        }

        if (debouncedSearchTerm) {
          await doSearch()
        }
      }

      void exec()
    }, [debouncedSearchTerm, groupBy, onSearch, open, triggerSearchOnFocus])

    const CreatableItem = () => {
      if (!creatable) return undefined
      if (
        isOptionsExist(options, [{ value: inputValue, label: inputValue }]) ||
        selected.find(s => s.value === inputValue)
      ) {
        return undefined
      }

      const Item = (
        <CommandItem
          value={inputValue}
          className='cursor-pointer'
          onMouseDown={e => {
            e.preventDefault()
            e.stopPropagation()
          }}
          onSelect={(value: string) => {
            if (selected.length >= maxSelected) {
              onMaxSelected?.(selected.length)
              return
            }
            setInputValue('')
            const newOptions = [...selected, { value, label: value }]
            setSelected(newOptions)
            onChange?.(newOptions)
          }}
        >
          {`Create "${inputValue}"`}
        </CommandItem>
      )

      if (!onSearch && inputValue.length > 0) {
        return Item
      }

      if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {
        return Item
      }

      return undefined
    }

    const EmptyItem = React.useCallback(() => {
      if (!emptyIndicator) return undefined

      if (onSearch && !creatable && Object.keys(options).length === 0) {
        return (
          <CommandItem value='-' disabled>
            {emptyIndicator}
          </CommandItem>
        )
      }

      return <CommandEmpty>{emptyIndicator}</CommandEmpty>
    }, [creatable, emptyIndicator, onSearch, options])

    const selectables = React.useMemo<GroupOption>(
      () => removePickedOption(options, selected),
      [options, selected]
    )

    const commandFilter = React.useCallback(() => {
      if (commandProps?.filter) {
        return commandProps.filter
      }

      if (creatable) {
        return (value: string, search: string) => {
          return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1
        }
      }
      return undefined
    }, [creatable, commandProps?.filter])

    return (
      <Command
        ref={dropdownRef}
        {...commandProps}
        onKeyDown={e => {
          handleKeyDown(e)
          commandProps?.onKeyDown?.(e)
        }}
        className={cn(
          'h-auto overflow-visible bg-transparent',
          commandProps?.className
        )}
        shouldFilter={
          commandProps?.shouldFilter !== undefined
            ? commandProps.shouldFilter
            : !onSearch
        }
        filter={commandFilter()}
      >
        <div
          className={cn(
            'min-h-10 rounded-md border border-input text-base ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 md:text-sm',
            className
          )}
        >
          <div className='relative flex flex-wrap gap-1 p-2'>
            {selected.map(option => (
              <Badge
                key={option.value}
                className={cn(
                  'max-h-[22px] data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground',
                  'data-[fixed]:bg-muted-foreground data-[fixed]:text-muted data-[fixed]:hover:bg-muted-foreground',
                  badgeClassName
                )}
                data-fixed={option.fixed}
                data-disabled={disabled || undefined}
              >
                {option.label}
                <button
                  className={cn(
                    'ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2',
                    (disabled || option.fixed) && 'hidden'
                  )}
                  onKeyDown={e => {
                    if (e.key === 'Enter') {
                      handleUnselect(option)
                    }
                  }}
                  onMouseDown={e => {
                    e.preventDefault()
                    e.stopPropagation()
                  }}
                  onClick={() => handleUnselect(option)}
                >
                  <X className='h-3 w-3 text-muted-foreground hover:text-foreground' />
                </button>
              </Badge>
            ))}
            <textarea
              ref={textareaRef}
              {...textareaProps}
              value={inputValue}
              disabled={disabled}
              onChange={e => {
                setInputValue(e.target.value)
                textareaProps?.onChange?.(e)
              }}
              onBlur={event => {
                if (!onScrollbar) {
                  setOpen(false)
                }
                textareaProps?.onBlur?.(event)
              }}
              onFocus={event => {
                setOpen(true)
                textareaProps?.onFocus?.(event)
              }}
              placeholder={
                hidePlaceholderWhenSelected && selected.length !== 0
                  ? ''
                  : placeholder
              }
              className={cn(
                'flex-1 bg-transparent outline-none placeholder:text-muted-foreground',
                {
                  'w-full': hidePlaceholderWhenSelected,
                  'px-3 py-2': selected.length === 0,
                  'ml-1': selected.length !== 0
                },
                textareaProps?.className
              )}
              style={{
                minHeight: `${minHeight}px`,
                maxHeight: `${maxHeight}px`
              }}
            />
          </div>
        </div>
        {/* Command List implementation */}
        <div className='relative'>
          {open && (
            <CommandList
              className='absolute top-1 z-10 w-full rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in'
              onMouseLeave={() => setOnScrollbar(false)}
              onMouseEnter={() => setOnScrollbar(true)}
              onMouseUp={() => textareaRef?.current?.focus()}
            >
              {isLoading ? (
                <>{loadingIndicator}</>
              ) : (
                <>
                  {EmptyItem()}
                  {CreatableItem()}
                  {!selectFirstItem && (
                    <CommandItem value='-' className='hidden' />
                  )}
                  {Object.entries(selectables).map(([key, dropdowns]) => (
                    <CommandGroup
                      key={key}
                      heading={key}
                      className='h-full overflow-auto'
                    >
                      {dropdowns.map(option => (
                        <CommandItem
                          key={option.value}
                          value={option.label}
                          disabled={option.disable}
                          onMouseDown={e => {
                            e.preventDefault()
                            e.stopPropagation()
                          }}
                          onSelect={() => {
                            if (selected.length >= maxSelected) {
                              onMaxSelected?.(selected.length)
                              return
                            }
                            setInputValue('')
                            const newOptions = [...selected, option]
                            setSelected(newOptions)
                            onChange?.(newOptions)
                          }}
                          className={cn(
                            'cursor-pointer',
                            option.disable &&
                              'cursor-default text-muted-foreground'
                          )}
                        >
                          {option.label}
                        </CommandItem>
                      ))}
                    </CommandGroup>
                  ))}
                </>
              )}
            </CommandList>
          )}
        </div>
      </Command>
    )
  }
)

MultipleTextAreaSelector.displayName = 'MultipleTextAreaSelector'
export default MultipleTextAreaSelector

Usage

Basic Usage

import { MultipleTextAreaSelector } from '@/components/ui/MultipleTextAreaSelector'
const OPTIONS = [
  { label: 'nextjs', value: 'nextjs' },
  { label: 'React', value: 'react' },
  { label: 'Vue', value: 'vue' }
]
export default function Demo() {
  return (
    <MultipleTextAreaSelector
      defaultOptions={OPTIONS}
      placeholder='Type or select frameworks...'
      minHeight={52}
      maxHeight={200}
    />
  )
}

With Creatable Options

import { MultipleTextAreaSelector } from '@/components/ui/MultipleTextAreaSelector'

const OPTIONS = [
  { label: 'nextjs', value: 'nextjs' },
  { label: 'React', value: 'react' },
  { label: 'Vue', value: 'vue' }
]

export default function Demo() {
  return (
    <MultipleTextAreaSelector
      defaultOptions={OPTIONS}
      placeholder="Type something that doesn't exist in dropdowns..."
      creatable
      minHeight={52}
      maxHeight={200}
      emptyIndicator={
        <p className='text-center text-lg leading-10 text-gray-600 dark:text-gray-400'>
          no results found.
        </p>
      }
    />
  )
}

Props

Extends all props from Multiple Selector with additional textarea-specific props:

Prop Type Description
minHeight number Minimum height of the textarea (default: 52)
maxHeight number Maximum height of the textarea (default: 200)
textareaProps TextareaHTMLAttributes HTML textarea attributes (excluding value, placeholder, disabled)

Acknowledgments

This component is inspired by:

  • Multiple Selector from shadcn-ui expansions
  • Built on top of the Command component from shadcn-ui
  • Uses cmdk under the hood

Notes

  • The badge height is intentionally limited to max-h-[22px] to maintain consistent UI when used with an expanding textarea
  • The textarea automatically resizes based on content while respecting min/max height constraints
  • All keyboard shortcuts and accessibility features from the original Multiple Selector are maintained
  • Press Enter to create a new tag when creatable mode is enabled (use Shift+Enter for new lines)
  • Feel free to contribute to the project by opening an issue or a PR.

Author

This component is created by Nidhi Yashwanth.

About

A shadcn/ui style fast, composable, fully-featured multiple selector with a textarea for React, inspired by the Multiple Selector in shadcnui-expansions.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published