teqo-backup/src/components/FormCombobox.tsx
Francisco Ignácio Fontoura Solla 712649eecf Replace state/city selects with type-to-filter comboboxes
Turn StateSelect and CitySelect into autocomplete comboboxes so long
lists no longer bias people toward the top option. A shared FormCombobox
wraps shadcn's (Base UI) Combobox and bridges it to react-hook-form via
useController, storing the state code / city name while displaying a
label. Matching is accent-insensitive and prefix-based; the list opens
after 1 character and results are alphabetical. State searches by both
full name and initials (new StateNames map).

Adds @base-ui/react + shadcn combobox/input-group primitives, and
documents our export-const and named-hook-import conventions in the
components rule (with the src/components/ui/* shadcn carve-out).
2026-07-15 14:41:58 -03:00

126 lines
3.2 KiB
TypeScript

'use client'
import { useCallback, useMemo, useState } from 'react'
import { type FieldPath, type FieldValues, useController, useFormContext } from 'react-hook-form'
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from '@/components/ui/combobox'
import { cn } from '@/lib/utils'
export interface ComboboxOption {
value: string
label: string
keywords?: string[]
}
interface FormComboboxProps<TFieldValues extends FieldValues> {
name: FieldPath<TFieldValues>
options: ComboboxOption[]
id?: string
placeholder?: string
/** Minimum characters typed before the list of options is shown. */
minChars?: number
disabled?: boolean
required?: boolean
emptyMessage?: string
className?: string
}
const normalize = (value: string) =>
value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
export const FormCombobox = <TFieldValues extends FieldValues = FieldValues>({
name,
options,
id,
placeholder,
minChars = 3,
disabled,
required,
emptyMessage = 'Nenhum resultado encontrado.',
className,
}: FormComboboxProps<TFieldValues>) => {
const { control } = useFormContext<TFieldValues>()
const {
field,
fieldState: { invalid },
} = useController({ name, control })
const [open, setOpen] = useState(false)
const selectedItem = useMemo(
() => options.find((option) => option.value === field.value) ?? null,
[options, field.value],
)
const filter = useCallback(
(item: ComboboxOption, query: string) => {
const normalizedQuery = normalize(query)
if (normalizedQuery.length < minChars) {
return false
}
return [item.label, item.value, ...(item.keywords ?? [])].some((candidate) =>
normalize(candidate).startsWith(normalizedQuery),
)
},
[minChars],
)
return (
<Combobox<ComboboxOption>
items={options}
value={selectedItem}
onValueChange={(value) => field.onChange(value ? value.value : '')}
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setOpen(false)
}
}}
onInputValueChange={(inputValue, details) => {
setOpen(details.reason === 'input-change' && normalize(inputValue).length >= minChars)
}}
openOnInputClick={false}
filter={filter}
isItemEqualToValue={(a, b) => a?.value === b?.value}
disabled={disabled}
>
<ComboboxInput
id={id}
name={field.name}
ref={field.ref}
onBlur={field.onBlur}
placeholder={placeholder}
required={required}
disabled={disabled}
showTrigger={false}
autoComplete="off"
aria-invalid={invalid || undefined}
className={cn(
'h-auto rounded-full border-(--field-border) bg-(--field-background)',
className,
)}
/>
<ComboboxContent>
<ComboboxEmpty>{emptyMessage}</ComboboxEmpty>
<ComboboxList>
{(item: ComboboxOption) => (
<ComboboxItem key={item.value} value={item}>
{item.label}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
)
}