Multi Select
Um seletor de múltiplas opções em drawer, com busca e limite de seleção
Código:
'use client';
import { cn } from '@/lib/utils';
import { cva } from 'class-variance-authority';
import { AnimatePresence, motion } from 'motion/react';
import { forwardRef, useEffect, useState } from 'react';
import { Control, Controller, useForm } from 'react-hook-form';
import { Badge } from '@/artiux/components/badge';
import { Button, ButtonProps } from '@/artiux/components/button';
import { ButtonGroup } from '@/artiux/components/buttonGroup';
import { Drawer, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle } from '@/artiux/components/drawer';
import { Icon } from '@/artiux/components/icons';
import { RippleContainer } from '@/artiux/components/rippleContainer';
import { TextField } from '@/artiux/components/textField';
export interface MultiSelectOption {
value: string;
label: string;
}
export interface MultiSelectProps {
name: string;
control: Control<any>;
options: MultiSelectOption[];
placeholder?: string;
title?: string;
description?: string;
size?: 'sm' | 'lg';
maxSelections?: number;
searchPlaceholder?: string;
className?: string;
disabled?: boolean;
footerClassName?: string;
buttonsProps?: ButtonProps;
}
export const multiSelectVariants = cva(
'w-full outline-none bg-input transition-all rounded-xl disabled:cursor-not-allowed disabled:opacity-50 duration-150 ease-out focus-within:bg-accent [&:hover:not(:focus-within)]:bg-accent cursor-pointer',
{
variants: {
size: {
lg: 'px-4 py-3 text-base min-h-[52px]',
sm: 'px-3 py-1.5 text-sm min-h-[40px]',
},
},
defaultVariants: {
size: 'lg',
},
}
);
export const chipVariants = cva(
'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium bg-primary text-primary-foreground',
{
variants: {
size: {
lg: 'text-sm px-2.5 py-1',
sm: 'text-xs px-2 py-0.5',
},
},
defaultVariants: {
size: 'lg',
},
}
);
function MultiSelectComponent(
{
name,
control,
options,
placeholder = 'Selecionar',
title,
description,
size = 'lg',
maxSelections,
searchPlaceholder = 'Buscar...',
className,
disabled = false,
footerClassName,
buttonsProps,
}: MultiSelectProps,
ref: React.ForwardedRef<HTMLDivElement>
) {
const [open, setOpen] = useState(false);
const {
control: searchControl,
watch,
reset: resetSearch,
} = useForm({
defaultValues: { search: '' },
});
const searchValue = watch('search');
const filteredOptions = !searchValue
? options
: options.filter((option) => option.label.toLowerCase().includes(searchValue.toLowerCase()));
useEffect(() => {
if (!open) {
resetSearch();
}
}, [open]);
return (
<Controller
name={name}
control={control}
render={({ field }) => {
const selectedValues: string[] = field.value || [];
const handleToggle = (value: string) => {
const isSelected = selectedValues.includes(value);
if (isSelected) {
field.onChange(selectedValues.filter((v) => v !== value));
} else {
if (maxSelections && selectedValues.length >= maxSelections) {
return;
}
field.onChange([...selectedValues, value]);
}
};
const handleRemove = (value: string, e: React.MouseEvent) => {
e.stopPropagation();
field.onChange(selectedValues.filter((v) => v !== value));
};
const getLabel = (value: string) => {
return options.find((opt) => opt.value === value)?.label || value;
};
const isMaxReached = maxSelections && selectedValues.length >= maxSelections;
return (
<>
{/* Trigger */}
<RippleContainer>
<div
ref={ref}
onClick={() => !disabled && setOpen(true)}
className={cn(
multiSelectVariants({ size }),
'relative flex w-full items-center justify-between gap-2',
disabled && 'cursor-not-allowed opacity-50',
className
)}
>
<div className='flex flex-1 flex-wrap items-center gap-1.5'>
<AnimatePresence mode='popLayout'>
{selectedValues.length > 0 ? (
selectedValues.map((value) => (
<motion.div
key={value}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{
type: 'spring',
stiffness: 500,
damping: 30,
mass: 0.8,
}}
>
<Badge
className='rounded-lg'
ornament='close'
ornamentPosition='right'
size='sm'
onClick={(e) => handleRemove(value, e)}
>
{getLabel(value)}
</Badge>
</motion.div>
))
) : (
<span className='text-muted-foreground'>{placeholder}</span>
)}
</AnimatePresence>
</div>
<Icon icon='chevron-down' className='text-primary shrink-0' />
</div>
</RippleContainer>
{/* Drawer */}
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent>
{(title || description) && (
<DrawerHeader>
{title && <DrawerTitle>{title}</DrawerTitle>}
{description && <DrawerDescription>{description}</DrawerDescription>}
</DrawerHeader>
)}
<section className='mb-4 block'>
{/* Search Input */}
<div className='my-2'>
<TextField
name='search'
control={searchControl}
ornament='search'
ornamentPosition='left'
placeholder={searchPlaceholder}
/>
</div>
{/* Selection info */}
{maxSelections && (
<span className='text-emphasis-low text-sm'>
{selectedValues.length} de {maxSelections} selecionados
</span>
)}
</section>
<ButtonGroup orientation={'vertical'}>
{filteredOptions.length === 0 ? (
<div className='text-muted-foreground py-4 text-center text-sm'>Nenhuma opção encontrada</div>
) : (
filteredOptions.map((option) => {
const isSelected = selectedValues.includes(option.value);
const isDisabled = !isSelected && isMaxReached;
return (
<Button
key={option.value}
variant='secondary'
className={cn('w-full', isDisabled && 'opacity-50')}
onClick={() => !isDisabled && handleToggle(option.value)}
ornament={isSelected ? 'check' : undefined}
ornamentPosition='right'
>
{option.label}
</Button>
);
})
)}
</ButtonGroup>
<DrawerFooter className={cn('flex max-h-[50vh] flex-row flex-wrap overflow-y-auto', footerClassName)}>
<Button onClick={() => setOpen(false)}>Confirmar</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
</>
);
}}
/>
);
}
export const MultiSelect = forwardRef(MultiSelectComponent);
yarn add class-variance-authority motion react-hook-form
Prévia:
Com limite de seleção
Selecione...
Tamanho pequeno
Selecione as tags
Size
Max selections
3
Disabled
Props:
| Property | Type | Default | Description |
|---|---|---|---|
name | string | - | Nome do campo no formulário (react-hook-form). |
control | Control<any> | - | Objeto de controle do react-hook-form. |
options | { value: string; label: string }[] | - | Lista de opções disponíveis para seleção. |
placeholder | string | 'Selecionar' | Texto exibido quando nenhuma opção está selecionada. |
title | string | - | Título exibido no cabeçalho do drawer. |
description | string | - | Descrição exibida no cabeçalho do drawer. |
size | 'sm' | 'lg' | 'lg' | Tamanho do campo de seleção. |
maxSelections | number | - | Limite máximo de opções selecionáveis. |
searchPlaceholder | string | 'Buscar...' | Placeholder do campo de busca no drawer. |
disabled | boolean | false | Desabilita a interação com o componente. |
footerClassName | string | - | Classes aplicadas ao rodapé do drawer. |
buttonsProps | ButtonProps | - | Props repassadas aos botões de opção do drawer. |
