mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
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:
parent
4f22824d4f
commit
712649eecf
9 changed files with 795 additions and 28 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
126
src/components/FormCombobox.tsx
Normal file
126
src/components/FormCombobox.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
299
src/components/ui/combobox.tsx
Normal file
299
src/components/ui/combobox.tsx
Normal 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,
|
||||
}
|
||||
156
src/components/ui/input-group.tsx
Normal file
156
src/components/ui/input-group.tsx
Normal 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
33
src/lib/states.ts
Normal 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',
|
||||
}
|
||||
Loading…
Reference in a new issue