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).
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-15 14:41:58 -03:00
parent 4f22824d4f
commit 712649eecf
9 changed files with 795 additions and 28 deletions

View file

@ -2,6 +2,48 @@
Custom Components allow you to fully customize the Admin Panel by swapping in your own React components. You can replace nearly every part of the interface or add entirely new functionality.
## Project Conventions (Teqo)
These conventions apply to all first-party components we author. The only exception is `src/components/ui/*`, which is generated/managed by the shadcn CLI and kept verbatim (including its `export function` declarations and `import * as React from 'react'` style) so it stays easy to update from upstream.
1. **Declare components with `export const`, not `export function`.** Use arrow functions:
```tsx
// ✅ First-party component
export const MyComponent = ({ title }: MyComponentProps) => {
return <h1>{title}</h1>
}
// ❌ Reserved for src/components/ui/* (shadcn) only
export function MyComponent({ title }: MyComponentProps) {
return <h1>{title}</h1>
}
```
For a generic component in a `.tsx` file, the `extends` clause keeps the type parameter unambiguous from JSX:
```tsx
export const FormField = <TFieldValues extends FieldValues = FieldValues>(
props: FormFieldProps<TFieldValues>,
) => {
/* ... */
}
```
2. **Import React hooks by name; do not use the `React.*` namespace.** Import each hook (and other named React APIs) directly:
```tsx
// ✅ First-party component
import { useCallback, useMemo, useState } from 'react'
const [open, setOpen] = useState(false)
// ❌ Reserved for src/components/ui/* (shadcn) only
import * as React from 'react'
const [open, setOpen] = React.useState(false)
```
## Component Types
There are four main types of Custom Components:

View file

@ -41,6 +41,7 @@
"test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts"
},
"dependencies": {
"@base-ui/react": "^1.6.0",
"@hookform/resolvers": "^5.2.2",
"@payloadcms/db-postgres": "3.79.0",
"@payloadcms/live-preview-react": "3.79.0",

View file

@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@base-ui/react':
specifier: ^1.6.0
version: 1.6.0(@date-fns/tz@1.2.0)(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.71.1(react@19.2.4))
@ -328,6 +331,10 @@ packages:
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
engines: {node: '>=6.9.0'}
'@babel/runtime@7.29.7':
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
engines: {node: '>=6.9.0'}
'@babel/template@7.28.6':
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
engines: {node: '>=6.9.0'}
@ -340,6 +347,33 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@base-ui/react@1.6.0':
resolution: {integrity: sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@date-fns/tz': ^1.2.0
'@types/react': ^17 || ^18 || ^19
date-fns: ^4.0.0
react: ^17 || ^18 || ^19
react-dom: ^17 || ^18 || ^19
peerDependenciesMeta:
'@date-fns/tz':
optional: true
'@types/react':
optional: true
date-fns:
optional: true
'@base-ui/utils@0.3.1':
resolution: {integrity: sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg==}
peerDependencies:
'@types/react': ^17 || ^18 || ^19
react: ^17 || ^18 || ^19
react-dom: ^17 || ^18 || ^19
peerDependenciesMeta:
'@types/react':
optional: true
'@borewit/text-codec@0.2.1':
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
@ -992,15 +1026,27 @@ packages:
'@floating-ui/core@1.7.4':
resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==}
'@floating-ui/core@1.8.0':
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
'@floating-ui/dom@1.7.5':
resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==}
'@floating-ui/dom@1.8.0':
resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==}
'@floating-ui/react-dom@2.1.7':
resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/react-dom@2.1.9':
resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/react@0.27.17':
resolution: {integrity: sha512-LGVZKHwmWGg6MRHjLLgsfyaX2y2aCNgnD1zT/E6B+/h+vxg+nIJUqHPAlTzsHDyqdgEpJ1Np5kxWuFEErXzoGg==}
peerDependencies:
@ -1010,6 +1056,9 @@ packages:
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
'@floating-ui/utils@0.2.12':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
'@hono/node-server@1.19.11':
resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==}
engines: {node: '>=18.14.1'}
@ -5498,6 +5547,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
reselect@5.2.0:
resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
resolve-dir@1.0.1:
resolution: {integrity: sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==}
engines: {node: '>=0.10.0'}
@ -6589,6 +6641,8 @@ snapshots:
'@babel/runtime@7.28.6': {}
'@babel/runtime@7.29.7': {}
'@babel/template@7.28.6':
dependencies:
'@babel/code-frame': 7.29.0
@ -6612,6 +6666,31 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@base-ui/react@1.6.0(@date-fns/tz@1.2.0)(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.29.7
'@base-ui/utils': 0.3.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@floating-ui/react-dom': 2.1.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@floating-ui/utils': 0.2.12
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
'@date-fns/tz': 1.2.0
'@types/react': 19.2.14
date-fns: 4.1.0
'@base-ui/utils@0.3.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.29.7
'@floating-ui/utils': 0.2.12
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
reselect: 5.2.0
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@borewit/text-codec@0.2.1': {}
'@csstools/color-helpers@6.0.1': {}
@ -7074,17 +7153,32 @@ snapshots:
dependencies:
'@floating-ui/utils': 0.2.10
'@floating-ui/core@1.8.0':
dependencies:
'@floating-ui/utils': 0.2.12
'@floating-ui/dom@1.7.5':
dependencies:
'@floating-ui/core': 1.7.4
'@floating-ui/utils': 0.2.10
'@floating-ui/dom@1.8.0':
dependencies:
'@floating-ui/core': 1.8.0
'@floating-ui/utils': 0.2.12
'@floating-ui/react-dom@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@floating-ui/dom': 1.7.5
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@floating-ui/react-dom@2.1.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@floating-ui/dom': 1.8.0
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@floating-ui/react@0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -7095,6 +7189,8 @@ snapshots:
'@floating-ui/utils@0.2.10': {}
'@floating-ui/utils@0.2.12': {}
'@hono/node-server@1.19.11(hono@4.12.7)':
dependencies:
hono: 4.12.7
@ -11983,6 +12079,8 @@ snapshots:
require-from-string@2.0.2: {}
reselect@5.2.0: {}
resolve-dir@1.0.1:
dependencies:
expand-tilde: 2.0.2

View file

@ -1,33 +1,43 @@
import { useFormContext, useWatch } from 'react-hook-form'
import { NativeSelect, NativeSelectOption } from './ui/native-select'
import { CitiesByState } from '@/lib/cities'
import { useEffect } from 'react'
import { useEffect, useMemo } from 'react'
import { useFormContext, useWatch } from 'react-hook-form'
import { FormCombobox, type ComboboxOption } from './FormCombobox'
interface CitySelectProps {
placeholder?: string
}
export const CitySelect = ({ placeholder = 'Selecione uma cidade' }: CitySelectProps) => {
const { register, control, setValue } = useFormContext()
const { control, setValue } = useFormContext()
const state: keyof typeof CitiesByState | undefined = useWatch({
control,
name: 'state',
})
const cityOptions = state?.length ? CitiesByState[state] : []
const options = useMemo<ComboboxOption[]>(
() =>
state
? CitiesByState[state]
.map((city) => ({ value: city, label: city }))
.sort((a, b) => a.label.localeCompare(b.label, 'pt-BR'))
: [],
[state],
)
useEffect(() => {
setValue('city', undefined)
}, [state])
setValue('city', '')
}, [state, setValue])
return (
<NativeSelect id="city" {...register('city')} disabled={!cityOptions.length} required>
<NativeSelectOption value="">{placeholder}</NativeSelectOption>
{cityOptions.map((city) => (
<NativeSelectOption key={city} value={city}>
{city}
</NativeSelectOption>
))}
</NativeSelect>
<FormCombobox
name="city"
id="city"
options={options}
placeholder={placeholder}
minChars={1}
disabled={!options.length}
required
emptyMessage="Nenhuma cidade encontrada."
/>
)
}

View file

@ -0,0 +1,126 @@
'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>
)
}

View file

@ -1,22 +1,24 @@
import { useFormContext } from 'react-hook-form'
import { NativeSelect, NativeSelectOption } from './ui/native-select'
import { CitiesByState } from '@/lib/cities'
import { StateNames } from '@/lib/states'
import { FormCombobox, type ComboboxOption } from './FormCombobox'
interface StateSelectProps {
placeholder?: string
}
export const StateSelect = ({ placeholder = 'Selecione um estado' }: StateSelectProps) => {
const { register } = useFormContext()
const stateOptions: ComboboxOption[] = Object.entries(StateNames)
.map(([code, name]) => ({ value: code, label: name, keywords: [code] }))
.sort((a, b) => a.label.localeCompare(b.label, 'pt-BR'))
export const StateSelect = ({ placeholder = 'Selecione um estado' }: StateSelectProps) => {
return (
<NativeSelect required {...register('state')}>
<NativeSelectOption value="">{placeholder}</NativeSelectOption>
{Object.keys(CitiesByState).map((state) => (
<NativeSelectOption key={state} value={state}>
{state}
</NativeSelectOption>
))}
</NativeSelect>
<FormCombobox
name="state"
id="state"
options={stateOptions}
placeholder={placeholder}
minChars={1}
required
emptyMessage="Nenhum estado encontrado."
/>
)
}

View file

@ -0,0 +1,299 @@
"use client"
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group"
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"
const Combobox = ComboboxPrimitive.Root
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</ComboboxPrimitive.Trigger>
)
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
)
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean
showClear?: boolean
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
asChild
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
>
<ComboboxTrigger />
</InputGroupButton>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
)
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn("group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
)
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
className
)}
{...props}
/>
)
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
)
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
)
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
className
)}
{...props}
/>
)
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
)
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
)
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null)
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
}

View file

@ -0,0 +1,156 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

33
src/lib/states.ts Normal file
View file

@ -0,0 +1,33 @@
import { CitiesByState } from '@/lib/cities'
export type StateKey = keyof typeof CitiesByState
export const StateNames: Record<StateKey, string> = {
AC: 'Acre',
AL: 'Alagoas',
AP: 'Amapá',
AM: 'Amazonas',
BA: 'Bahia',
CE: 'Ceará',
DF: 'Distrito Federal',
ES: 'Espírito Santo',
GO: 'Goiás',
MA: 'Maranhão',
MT: 'Mato Grosso',
MS: 'Mato Grosso do Sul',
MG: 'Minas Gerais',
PA: 'Pará',
PB: 'Paraíba',
PR: 'Paraná',
PE: 'Pernambuco',
PI: 'Piauí',
RJ: 'Rio de Janeiro',
RN: 'Rio Grande do Norte',
RS: 'Rio Grande do Sul',
RO: 'Rondônia',
RR: 'Roraima',
SC: 'Santa Catarina',
SP: 'São Paulo',
SE: 'Sergipe',
TO: 'Tocantins',
}