diff --git a/docs/components/form/autocomplete-input.md b/docs/components/form/autocomplete-input.md new file mode 100644 index 0000000..a43a537 --- /dev/null +++ b/docs/components/form/autocomplete-input.md @@ -0,0 +1,124 @@ +# Documentação do Componente AutocompleteInput (`autocomplete-input.tsx`) + +O arquivo `autocomplete-input.tsx` define um componente React reutilizável e flexível que oferece uma funcionalidade de autocompletar (combobox). Ele foi projetado para funcionar tanto de forma autônoma (não controlada) quanto controlada por um estado externo, como um formulário. + +--- + +## ☑️ Objetivo do Componente + +- Permitir que os usuários selecionem uma opção de uma lista filtrável de forma rápida e intuitiva. +- Oferecer uma experiência de usuário aprimorada para seleção de itens em listas grandes. +- Ser flexível o suficiente para ser usado em qualquer contexto: dentro de formulários complexos ou como um seletor independente. +- Garantir acessibilidade e conformidade com padrões web, especialmente para navegação por teclado. + +--- + +## 📦 Principais Importações + +- `cmdk`: A base para a funcionalidade de autocompletar e menu de comandos. +- `lucide-react`: Ícones para indicar seleção e abertura/fechamento. +- `cn` de `@/utils/class-name-merge`: Função utilitária para unir classes CSS dinamicamente. +- Componentes de UI internos (`Button`, `Label`, `Popover`, etc.): Para garantir consistência visual com o projeto. + +--- + +## ⚙️ Lógica do Componente: Controlado vs. Não Controlado + +O `AutocompleteInput` possui uma lógica de estado dual, tornando-o extremamente versátil. + +### Modo Não Controlado (Padrão) + +- **Como funciona:** Este é o modo padrão. O componente gerencia seu próprio estado internamente usando `useState`, começando sempre com um valor vazio. +- **Quando usar:** Ideal para casos de uso simples, onde você só precisa de um seletor funcional na tela sem a complexidade de um formulário. +- **Propriedades-chave:** + - `onChange`: Você pode usar `onChange` para ser notificado quando o valor mudar, mesmo neste modo. + +### Modo Controlado + +- **Como funciona:** Este modo é ativado quando você passa a propriedade `value`. Nesse cenário, o componente cede todo o controle do estado para o componente pai. +- **Quando usar:** Essencial ao integrar com bibliotecas de formulário (`react-hook-form`) ou quando o estado do seletor precisa ser acessado ou modificado por outros componentes. +- **Propriedades-chave:** + - `value`: Define o valor exibido pelo componente. O componente sempre refletirá este valor. + - `onChange`: Uma função que o componente chama com o novo valor sempre que o usuário faz uma seleção. É sua responsabilidade usar esta função para atualizar o estado que você está passando para a prop `value`. + +--- + +## 🧩 Propriedades do Componente + +```tsx +interface AutocompleteInputProps { + name?: string + label: string | ReactNode + options: SelectOption[] + value?: string + onChange?: (value: string) => void + isRequired?: boolean + placeholder?: string + message?: string + wrapperClassName?: string + error?: boolean +} +``` + +- `name` (`string`, opcional): Usado para acessibilidade (`label htmlFor`). +- `label` (`string | ReactNode`): O rótulo a ser exibido acima do input. +- `options` (`SelectOption[]`): Array de objetos `{ label: string, value: string }` com as opções. +- `value` (`string`, opcional): Ativa o **modo controlado**. O valor a ser exibido. +- `onChange` (`(value: string) => void`, opcional): Callback chamado quando o valor muda. +- `isRequired` (`boolean`, opcional): Exibe o indicador de campo obrigatório. +- `placeholder` (`string`, opcional): Texto de placeholder. +- `message` (`string`, opcional): Mensagem a ser exibida abaixo do input. +- `wrapperClassName` (`string`, opcional): Classes CSS para o elemento wrapper. +- `error` (`boolean`, opcional): Ativa o estilo de erro (ex: borda vermelha). + +--- + +### 📝 Exemplos de Uso + +```tsx +const cities = [ + { label: 'São Paulo', value: 'SP' }, + { label: 'Rio de Janeiro', value: 'RJ' }, +] + +// 1. Modo Não Controlado (uso simples e direto) + console.log(value)} +/> + +// 2. Modo Controlado (com useState) +import { useState } from 'react' + +const [city, setCity] = useState('') + + + +// 3. Modo Controlado (com React Hook Form) +import { Controller } from 'react-hook-form' + + ( + + )} +/> +``` diff --git a/package-lock.json b/package-lock.json index 58e1a3e..e7e22a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@tanstack/react-query": "5.83.0", "class-variance-authority": "0.7.1", "clsx": "2.1.1", + "cmdk": "1.1.1", "date-fns": "4.1.0", "jsonwebtoken": "9.0.2", "lucide-react": "0.531.0", @@ -5943,6 +5944,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", diff --git a/package.json b/package.json index a1bb9b3..7d57fbf 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@tanstack/react-query": "5.83.0", "class-variance-authority": "0.7.1", "clsx": "2.1.1", + "cmdk": "1.1.1", "date-fns": "4.1.0", "jsonwebtoken": "9.0.2", "lucide-react": "0.531.0", diff --git a/src/components/form/autocomplete-input.tsx b/src/components/form/autocomplete-input.tsx new file mode 100644 index 0000000..ef69dc7 --- /dev/null +++ b/src/components/form/autocomplete-input.tsx @@ -0,0 +1,206 @@ +'use client' + +import { + Command as Cmdk, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from 'cmdk' +import { CheckIcon, ChevronsUpDown } from 'lucide-react' +import type { ReactNode } from 'react' +import React, { forwardRef, useState } from 'react' + +import { cn } from '@/utils/class-name-merge' + +import { Button } from '../ui/button' +import { Label } from '../ui/label' +import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover' +import { FormMessage } from './form-message' +import { RequiredInput } from './required-input' + +interface SelectOption { + label: string + value: string +} + +interface AutocompleteInputProps { + name?: string + label: string | ReactNode + options: SelectOption[] + value?: string + onChange?: (value: string) => void + isRequired?: boolean + placeholder?: string + message?: string + wrapperClassName?: string + error?: boolean +} + +export const AutocompleteInput = forwardRef< + React.ElementRef, + AutocompleteInputProps +>( + ( + { + name, + label, + options, + message, + isRequired, + placeholder, + wrapperClassName, + value, + onChange, + error, + ...props + }, + ref, + ) => { + const [open, setOpen] = useState(false) + const [internalValue, setInternalValue] = useState('') + + const isControlled = value !== undefined + const currentValue = isControlled ? value : internalValue + + const selectedOption = options.find( + (option) => option.value === currentValue, + ) + + function handleSelect(selectedValue: string) { + const newValue = selectedValue === currentValue ? '' : selectedValue + + if (!isControlled) { + setInternalValue(newValue) + } + onChange?.(newValue) + + setOpen(false) + } + + return ( +
+ + + + + + + + e.preventDefault()} + > + + + + + Nenhuma opção encontrada. + + + {options.map((option) => ( + + {option.label} + + + ))} + + + + + + {message} +
+ ) + }, +) + +AutocompleteInput.displayName = 'AutocompleteInput' + +/* USAGE + +const cities = [ + { label: 'São Paulo', value: 'SP' }, + { label: 'Rio de Janeiro', value: 'RJ' }, +]; + +// 1. Uncontrolled (standalone, recommended for simple cases) + + console.log(value)} +/> + +// 2. Controlled (for forms or complex state management) +// State is managed by a parent component. +import { useState } from 'react'; + +const [city, setCity] = useState(''); + + + +// 3. With React Hook Form (as a controlled component) +import { Controller } from 'react-hook-form'; + + ( + + )} +/> + +*/ diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 0000000..057711e --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,30 @@ +import * as PopoverPrimitive from '@radix-ui/react-popover' +import { ComponentPropsWithoutRef, forwardRef } from 'react' + +import { cn } from '@/utils/class-name-merge' + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverContent = forwardRef< + React.ElementRef, + ComponentPropsWithoutRef +>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverContent, PopoverTrigger }