better-forms

安装量: 35
排名: #19842

安装

npx skills add https://github.com/alexeira/skills --skill better-forms
Better Forms Guide
A collection of specific UX patterns, accessibility standards, and implementation techniques for modern web forms. This guide bridges the gap between raw HTML/CSS tips and component-based architectures (React, Tailwind, Headless UI).
1. High-Impact UX Patterns (The "Why" & "How")
Avoid "Dead Zones" in Lists
Concept
Small gaps between clickable list items create frustration.
Implementation (Tailwind)
Use a pseudo-element to expand the hit area without affecting layout.
// Do this for list items or radio groups
<
div
className
=
"
relative group
"
>
<
input
type
=
"
radio
"
className
=
"
...
"
/>
<
label
className
=
"
... after:absolute after:inset-y-[-10px] after:left-0 after:right-0 after:content-['']
"
>
Option Label
</
label
>
</
div
>
Range Sliders > Min/Max Inputs
Concept
"From $10 to $1000" text inputs are tedious.
Implementation
Use a dual-thumb slider component (like Radix UI / Shadcn Slider) for ranges.
Why
Cognitive load reduction and immediate visual feedback.
A11y
Ensure the slider supports arrow key navigation.
"Output-Inspired" Design
Concept
The form inputs should visually resemble the final result card/page.
Hierarchy
If the output title is
text-2xl font-bold
, the input for it should be
text-2xl font-bold
.
Placement
If the image goes on the left in the listing, the upload button goes on the left in the form.
Empty States
Preview what the empty card looks like while filling it.
Descriptive Action Buttons
Concept
Never use "Submit" or "Send". The button should complete the sentence "I want to..."
Avoid:
Submit
Prefer:
Create Account
,
Publish Listing
,
Update Profile
Tip
Update button text dynamically based on form state (e.g., "Saving..." vs "Save Changes").
"Optional" Label > Asterisks
Concept
Red asterisks (*) are aggressive and ambiguous (sometimes meaning "error").
Implementation
Mark required fields by default (no indicator) and explicitly label optional ones.
<
Label
>
Phone Number
{
" "
}
<
span
className
=
"
text-muted-foreground text-sm font-normal
"
>
(Optional)
</
span
>
</
Label
>
Show/Hide Password
Concept
Masking passwords by default prevents error correction.
Implementation
Always include a toggle button inside the input wrapper.
A11y
The toggle button must have
type="button"
and
aria-label="Show password"
.
Field Sizing as Affordance
Concept
The width of the input suggests the expected data length.
Zip Code
:
w-20
or
w-24
(not full width).
CVV
Small width.
Street Address
Full width.
2. Advanced UX Patterns
Input Masking & Formatting
Concept
Auto-format data as the user types to reduce errors and cognitive load. // Phone number formatting with react-number-format import { PatternFormat } from "react-number-format" ; < PatternFormat format = " (###) ###-#### " mask = " _ " allowEmptyFormatting customInput = { Input } // Your styled input component onValueChange = { ( values ) => { // values.value = "1234567890" (raw) // values.formattedValue = "(123) 456-7890" form . setValue ( "phone" , values . value ) ; } } /> ; // Credit card with automatic spacing < PatternFormat format = "

#### ####

"
customInput
=
{
Input
}
onValueChange
=
{
(
values
)
=>
form
.
setValue
(
"cardNumber"
,
values
.
value
)
}
/>
;
// Currency input
import
{
NumericFormat
}
from
"react-number-format"
;
<
NumericFormat
thousandSeparator
=
"
,
"
prefix
=
"
$
"
decimalScale
=
{
2
}
fixedDecimalScale
customInput
=
{
Input
}
onValueChange
=
{
(
values
)
=>
form
.
setValue
(
"amount"
,
values
.
floatValue
)
}
/>
;
Key Principle
Store raw values, display formatted values. Never validate formatted strings.
OTP / 2FA Code Inputs
Concept
6-digit verification codes need special handling for paste, auto-focus, and keyboard navigation.
import
{
useRef
,
useState
,
useCallback
,
ClipboardEvent
,
KeyboardEvent
}
from
"react"
;
interface
OTPInputProps
{
length
?
:
number
;
onComplete
:
(
code
:
string
)
=>
void
;
}
export
function
OTPInput
(
{
length
=
6
,
onComplete
}
:
OTPInputProps
)
{
const
[
values
,
setValues
]
=
useState
<
string
[
]
>
(
Array
(
length
)
.
fill
(
""
)
)
;
const
inputRefs
=
useRef
<
(
HTMLInputElement
|
null
)
[
]
>
(
[
]
)
;
const
focusInput
=
useCallback
(
(
index
:
number
)
=>
{
const
clampedIndex
=
Math
.
max
(
0
,
Math
.
min
(
index
,
length
-
1
)
)
;
inputRefs
.
current
[
clampedIndex
]
?.
focus
(
)
;
}
,
[
length
]
)
;
const
handleChange
=
(
index
:
number
,
value
:
string
)
=>
{
if
(
!
/
^
\d
*
$
/
.
test
(
value
)
)
return
;
// Only digits
const
newValues
=
[
...
values
]
;
newValues
[
index
]
=
value
.
slice
(
-
1
)
;
// Take last digit only
setValues
(
newValues
)
;
if
(
value
&&
index
<
length
-
1
)
{
focusInput
(
index
+
1
)
;
}
const
code
=
newValues
.
join
(
""
)
;
if
(
code
.
length
===
length
)
{
onComplete
(
code
)
;
}
}
;
const
handleKeyDown
=
(
index
:
number
,
e
:
KeyboardEvent
<
HTMLInputElement
>
)
=>
{
switch
(
e
.
key
)
{
case
"Backspace"
:
if
(
!
values
[
index
]
&&
index
>
0
)
{
focusInput
(
index
-
1
)
;
}
break
;
case
"ArrowLeft"
:
e
.
preventDefault
(
)
;
focusInput
(
index
-
1
)
;
break
;
case
"ArrowRight"
:
e
.
preventDefault
(
)
;
focusInput
(
index
+
1
)
;
break
;
}
}
;
const
handlePaste
=
(
e
:
ClipboardEvent
)
=>
{
e
.
preventDefault
(
)
;
const
pastedData
=
e
.
clipboardData
.
getData
(
"text"
)
.
replace
(
/
\D
/
g
,
""
)
.
slice
(
0
,
length
)
;
if
(
pastedData
)
{
const
newValues
=
[
...
values
]
;
pastedData
.
split
(
""
)
.
forEach
(
(
char
,
i
)
=>
{
newValues
[
i
]
=
char
;
}
)
;
setValues
(
newValues
)
;
focusInput
(
pastedData
.
length
-
1
)
;
if
(
pastedData
.
length
===
length
)
{
onComplete
(
pastedData
)
;
}
}
}
;
return
(
<
div
className
=
"
flex gap-2
"
role
=
"
group
"
aria-label
=
"
Verification code
"
>
{
values
.
map
(
(
value
,
index
)
=>
(
<
input
key
=
{
index
}
ref
=
{
(
el
)
=>
{
inputRefs
.
current
[
index
]
=
el
;
}
}
type
=
"
text
"
inputMode
=
"
numeric
"
maxLength
=
{
1
}
value
=
{
value
}
onChange
=
{
(
e
)
=>
handleChange
(
index
,
e
.
target
.
value
)
}
onKeyDown
=
{
(
e
)
=>
handleKeyDown
(
index
,
e
)
}
onPaste
=
{
handlePaste
}
className
=
"
h-12 w-12 text-center text-lg font-semibold border rounded-md
focus:ring-2 focus:ring-ring focus:border-transparent
"
aria-label
=
{
`
Digit
${
index
+
1
}
of
${
length
}
`
}
/>
)
)
}
</
div
>
)
;
}
Unsaved Changes Protection
Concept
Prevent accidental data loss when navigating away from a dirty form.
Note (React 19)
Don't confuse
useFormState
from
react-hook-form
with React DOM's
useFormState
, which was renamed to
useActionState
in React 19.
Warning
Monkey-patching
router.push
is fragile and may break across Next.js versions. There is no stable API for intercepting App Router navigation. The
beforeunload
approach is the only reliable part. Consider using
onBeforePopState
(Pages Router) or a route change event listener if your framework supports it.
import
{
useEffect
}
from
"react"
;
import
{
useFormState
}
from
"react-hook-form"
;
export
function
useUnsavedChangesWarning
(
isDirty
:
boolean
,
message
?
:
string
)
{
const
warningMessage
=
message
??
"You have unsaved changes. Are you sure you want to leave?"
;
// Browser back/refresh — this is the reliable approach
useEffect
(
(
)
=>
{
const
handleBeforeUnload
=
(
e
:
BeforeUnloadEvent
)
=>
{
if
(
!
isDirty
)
return
;
e
.
preventDefault
(
)
;
}
;
window
.
addEventListener
(
"beforeunload"
,
handleBeforeUnload
)
;
return
(
)
=>
window
.
removeEventListener
(
"beforeunload"
,
handleBeforeUnload
)
;
}
,
[
isDirty
]
)
;
// For in-app navigation, consider a confirmation modal triggered
// from your navigation components rather than monkey-patching the router.
}
// Usage with React Hook Form
function
EditProfileForm
(
)
{
const
form
=
useForm
<
ProfileData
>
(
)
;
const
{
isDirty
}
=
useFormState
(
{
control
:
form
.
control
}
)
;
useUnsavedChangesWarning
(
isDirty
)
;
return
<
form
>
...
</
form
>
;
}
Multi-Step Forms (Wizards)
Concept
Break complex forms into digestible steps with proper state persistence and focus management.
import
{
useState
,
useEffect
,
useRef
,
useCallback
}
from
"react"
;
import
{
useForm
,
FormProvider
}
from
"react-hook-form"
;
import
{
zodResolver
}
from
"@hookform/resolvers/zod"
;
// Persist state to URL for refresh resilience
// Uses lazy state init to read from URL on first render (SSR-safe)
function
useStepFromURL
(
)
{
const
[
step
,
setStep
]
=
useState
(
(
)
=>
{
if
(
typeof
window
===
"undefined"
)
return
1
;
const
params
=
new
URLSearchParams
(
window
.
location
.
search
)
;
return
parseInt
(
params
.
get
(
"step"
)
??
"1"
,
10
)
;
}
)
;
const
goToStep
=
useCallback
(
(
newStep
:
number
)
=>
{
setStep
(
newStep
)
;
const
url
=
new
URL
(
window
.
location
.
href
)
;
url
.
searchParams
.
set
(
"step"
,
String
(
newStep
)
)
;
window
.
history
.
pushState
(
{
}
,
""
,
url
)
;
}
,
[
]
)
;
return
{
step
,
goToStep
}
;
}
// Focus management on step change
function
useStepFocus
(
step
:
number
)
{
const
headingRef
=
useRef
<
HTMLHeadingElement
>
(
null
)
;
useEffect
(
(
)
=>
{
// Focus the step heading for screen reader announcement
headingRef
.
current
?.
focus
(
)
;
}
,
[
step
]
)
;
return
headingRef
;
}
// Example multi-step form
interface
WizardFormData
{
// Step 1
firstName
:
string
;
lastName
:
string
;
// Step 2
email
:
string
;
phone
:
string
;
// Step 3
address
:
string
;
city
:
string
;
}
const
stepSchemas
=
{
1
:
z
.
object
(
{
firstName
:
z
.
string
(
)
.
min
(
1
)
,
lastName
:
z
.
string
(
)
.
min
(
1
)
}
)
,
2
:
z
.
object
(
{
email
:
z
.
string
(
)
.
email
(
)
,
phone
:
z
.
string
(
)
.
optional
(
)
}
)
,
3
:
z
.
object
(
{
address
:
z
.
string
(
)
.
min
(
1
)
,
city
:
z
.
string
(
)
.
min
(
1
)
}
)
,
}
;
export
function
WizardForm
(
)
{
const
{
step
,
goToStep
}
=
useStepFromURL
(
)
;
const
headingRef
=
useStepFocus
(
step
)
;
const
totalSteps
=
3
;
const
form
=
useForm
<
WizardFormData
>
(
{
resolver
:
zodResolver
(
stepSchemas
[
step
as
keyof
typeof
stepSchemas
]
)
,
mode
:
"onBlur"
,
}
)
;
// Persist draft to localStorage
useEffect
(
(
)
=>
{
const
saved
=
localStorage
.
getItem
(
"wizard-draft"
)
;
if
(
saved
)
{
form
.
reset
(
JSON
.
parse
(
saved
)
)
;
}
}
,
[
]
)
;
useEffect
(
(
)
=>
{
const
subscription
=
form
.
watch
(
(
data
)
=>
{
localStorage
.
setItem
(
"wizard-draft"
,
JSON
.
stringify
(
data
)
)
;
}
)
;
return
(
)
=>
subscription
.
unsubscribe
(
)
;
}
,
[
form
]
)
;
const
handleNext
=
async
(
)
=>
{
const
isValid
=
await
form
.
trigger
(
)
;
if
(
isValid
&&
step
<
totalSteps
)
{
goToStep
(
step
+
1
)
;
}
}
;
const
handleBack
=
(
)
=>
{
if
(
step
>
1
)
goToStep
(
step
-
1
)
;
}
;
return
(
<
FormProvider
{
...
form
}
>
{
/ Progress indicator /
}
<
div
role
=
"
progressbar
"
aria-valuenow
=
{
step
}
aria-valuemin
=
{
1
}
aria-valuemax
=
{
totalSteps
}
>
Step
{
step
}
of
{
totalSteps
}
</
div
>
{
/ Step heading - focused on navigation /
}
<
h2
ref
=
{
headingRef
}
tabIndex
=
{
-
1
}
className
=
"
outline-none
"
>
{
step
===
1
&&
"Personal Information"
}
{
step
===
2
&&
"Contact Details"
}
{
step
===
3
&&
"Address"
}
</
h2
>
<
form
onSubmit
=
{
form
.
handleSubmit
(
onSubmit
)
}
>
{
step
===
1
&&
<
StepOne
/>
}
{
step
===
2
&&
<
StepTwo
/>
}
{
step
===
3
&&
<
StepThree
/>
}
<
div
className
=
"
flex gap-4 mt-6
"
>
{
step
>
1
&&
(
<
button
type
=
"
button
"
onClick
=
{
handleBack
}
>
Back
</
button
>
)
}
{
step
<
totalSteps
?
(
<
button
type
=
"
button
"
onClick
=
{
handleNext
}
>
Continue
</
button
>
)
:
(
<
button
type
=
"
submit
"
>
Complete Registration
</
button
>
)
}
</
div
>
</
form
>
</
FormProvider
>
)
;
}
3. Backend Integration Patterns
Server-Side Error Mapping
Concept
Map API validation errors back to specific form fields.
import
{
useForm
,
UseFormReturn
}
from
"react-hook-form"
;
interface
APIError
{
field
:
string
;
message
:
string
;
}
interface
APIResponse
{
success
:
boolean
;
errors
?
:
APIError
[
]
;
}
// Usage: pass form instance and call inside component
function
useServerErrorHandler
<
T
extends
Record
<
string
,
unknown
>>
(
form
:
UseFormReturn
<
T
>
)
{
return
async
(
data
:
T
)
=>
{
const
response
=
await
fetch
(
"/api/register"
,
{
method
:
"POST"
,
body
:
JSON
.
stringify
(
data
)
,
}
)
;
const
result
:
APIResponse
=
await
response
.
json
(
)
;
if
(
!
result
.
success
&&
result
.
errors
)
{
// Map server errors to form fields
result
.
errors
.
forEach
(
(
error
)
=>
{
form
.
setError
(
error
.
field
as
keyof
T
&
string
,
{
type
:
"server"
,
message
:
error
.
message
,
}
)
;
}
)
;
// Focus the first errored field
const
firstErrorField
=
result
.
errors
[
0
]
?.
field
;
if
(
firstErrorField
)
{
form
.
setFocus
(
firstErrorField
as
keyof
T
&
string
)
;
}
return
;
}
// Success handling
}
;
}
// For nested errors (e.g., "address.city")
function
mapNestedError
(
form
:
UseFormReturn
,
path
:
string
,
message
:
string
)
{
form
.
setError
(
path
as
any
,
{
type
:
"server"
,
message
}
)
;
}
Debounced Async Validation
Concept
Validate expensive fields (username availability) without API overload.
import
{
useEffect
,
useMemo
,
useRef
,
useState
}
from
"react"
;
import
debounce
from
"lodash.debounce"
;
// Custom hook for async field validation
// Uses ref for validateFn to keep debounce stable and avoid timer resets.
// Cancels in-flight debounced calls on unmount to prevent memory leaks.
function
useAsyncValidation
<
T
>
(
validateFn
:
(
value
:
T
)
=>
Promise
<
string
|
null
>
,
delay
=
500
)
{
const
[
isValidating
,
setIsValidating
]
=
useState
(
false
)
;
const
[
error
,
setError
]
=
useState
<
string
|
null
>
(
null
)
;
const
validateFnRef
=
useRef
(
validateFn
)
;
validateFnRef
.
current
=
validateFn
;
const
debouncedValidate
=
useMemo
(
(
)
=>
debounce
(
async
(
value
:
T
)
=>
{
setIsValidating
(
true
)
;
try
{
const
result
=
await
validateFnRef
.
current
(
value
)
;
setError
(
result
)
;
}
finally
{
setIsValidating
(
false
)
;
}
}
,
delay
)
,
[
delay
]
)
;
// Cleanup debounce timer on unmount
useEffect
(
(
)
=>
(
)
=>
debouncedValidate
.
cancel
(
)
,
[
debouncedValidate
]
)
;
return
{
validate
:
debouncedValidate
,
isValidating
,
error
}
;
}
// Usage with React Hook Form
// Validation runs in the onChange handler (not via effects) to avoid
// race conditions and unnecessary re-renders.
function
UsernameField
(
)
{
const
{
register
,
setError
,
clearErrors
}
=
useFormContext
(
)
;
const
[
isChecking
,
setIsChecking
]
=
useState
(
false
)
;
const
checkUsername
=
async
(
value
:
string
)
:
Promise
<
string
|
null
>
=>
{
if
(
!
value
||
value
.
length
<
3
)
return
null
;
const
response
=
await
fetch
(
`
/api/check-username?username=
${
encodeURIComponent
(
value
)
}
`
)
;
const
{
available
}
=
await
response
.
json
(
)
;
return
available
?
null
:
"This username is already taken"
;
}
;
const
{
validate
,
isValidating
}
=
useAsyncValidation
(
checkUsername
)
;
// Derive combined checking state
const
showChecking
=
isChecking
||
isValidating
;
const
{
onChange
:
rhfOnChange
,
...
rest
}
=
register
(
"username"
,
{
onChange
:
(
e
)
=>
{
const
value
=
e
.
target
.
value
;
if
(
value
&&
value
.
length
>=
3
)
{
setIsChecking
(
true
)
;
validate
(
value
)
;
}
else
{
clearErrors
(
"username"
)
;
}
}
,
// Also validate via RHF's built-in async validate for submit-time
validate
:
async
(
value
)
=>
{
const
result
=
await
checkUsername
(
value
)
;
return
result
??
true
;
}
,
}
)
;
return
(
<
div
>
<
input
onChange
=
{
rhfOnChange
}
{
...
rest
}
/>
{
showChecking
&&
<
span
className
=
"
text-muted-foreground
"
>
Checking...
</
span
>
}
</
div
>
)
;
}
Optimistic Updates
Concept
Show immediate feedback while the request is in flight.
import
{
useTransition
,
useState
}
from
"react"
;
type
SubmitState
=
"idle"
|
"submitting"
|
"success"
|
"error"
;
function
ProfileForm
(
)
{
const
[
isPending
,
startTransition
]
=
useTransition
(
)
;
const
[
submitState
,
setSubmitState
]
=
useState
<
SubmitState
>
(
"idle"
)
;
const
[
optimisticData
,
setOptimisticData
]
=
useState
<
ProfileData
|
null
>
(
null
)
;
async
function
handleSubmit
(
data
:
ProfileData
)
{
// Immediately show optimistic update
setOptimisticData
(
data
)
;
setSubmitState
(
"submitting"
)
;
startTransition
(
async
(
)
=>
{
try
{
await
updateProfile
(
data
)
;
setSubmitState
(
"success"
)
;
// Clear success state after delay
setTimeout
(
(
)
=>
setSubmitState
(
"idle"
)
,
2000
)
;
}
catch
(
error
)
{
// Revert optimistic update
setOptimisticData
(
null
)
;
setSubmitState
(
"error"
)
;
}
}
)
;
}
return
(
<
form
onSubmit
=
{
form
.
handleSubmit
(
handleSubmit
)
}
>
{
/ Show optimistic preview /
}
{
optimisticData
&&
(
<
div
className
=
"
opacity-70
"
>
Preview:
{
optimisticData
.
name
}
</
div
>
)
}
<
button
type
=
"
submit
"
disabled
=
{
isPending
}
>
{
submitState
===
"submitting"
&&
"Saving..."
}
{
submitState
===
"success"
&&
"Saved!"
}
{
submitState
===
"error"
&&
"Try Again"
}
{
submitState
===
"idle"
&&
"Save Changes"
}
</
button
>
</
form
>
)
;
}
4. Complex Component Patterns
Accessible File Upload (Drag & Drop)
Concept
Drag-and-drop zones are often inaccessible. Ensure keyboard and screen reader support.
import
{
useCallback
,
useId
,
useState
,
useRef
}
from
"react"
;
interface
FileUploadProps
{
accept
?
:
string
;
maxSize
?
:
number
;
// bytes
onUpload
:
(
files
:
File
[
]
)
=>
void
;
}
export
function
AccessibleFileUpload
(
{
accept
,
maxSize
,
onUpload
}
:
FileUploadProps
)
{
const
[
isDragOver
,
setIsDragOver
]
=
useState
(
false
)
;
const
[
error
,
setError
]
=
useState
<
string
|
null
>
(
null
)
;
const
inputRef
=
useRef
<
HTMLInputElement
>
(
null
)
;
const
dropzoneId
=
useId
(
)
;
const
errorId
=
`
${
dropzoneId
}
-error
`
;
const
handleFiles
=
useCallback
(
(
files
:
FileList
|
null
)
=>
{
setError
(
null
)
;
if
(
!
files
?.
length
)
return
;
const
validFiles
:
File
[
]
=
[
]
;
Array
.
from
(
files
)
.
forEach
(
(
file
)
=>
{
if
(
maxSize
&&
file
.
size
>
maxSize
)
{
setError
(
`
${
file
.
name
}
exceeds maximum size
`
)
;
return
;
}
validFiles
.
push
(
file
)
;
}
)
;
if
(
validFiles
.
length
)
{
onUpload
(
validFiles
)
;
}
}
,
[
maxSize
,
onUpload
]
)
;
const
handleDrop
=
useCallback
(
(
e
:
React
.
DragEvent
)
=>
{
e
.
preventDefault
(
)
;
setIsDragOver
(
false
)
;
handleFiles
(
e
.
dataTransfer
.
files
)
;
}
,
[
handleFiles
]
)
;
const
handleKeyDown
=
(
e
:
React
.
KeyboardEvent
)
=>
{
if
(
e
.
key
===
"Enter"
||
e
.
key
===
" "
)
{
e
.
preventDefault
(
)
;
inputRef
.
current
?.
click
(
)
;
}
}
;
return
(
<
div
>
{
/ Hidden but accessible file input /
}
<
input
ref
=
{
inputRef
}
type
=
"
file
"
accept
=
{
accept
}
onChange
=
{
(
e
)
=>
handleFiles
(
e
.
target
.
files
)
}
className
=
"
sr-only
"
id
=
{
dropzoneId
}
aria-describedby
=
{
error
?
errorId
:
undefined
}
/>
{
/ Clickable and keyboard-accessible dropzone /
}
<
label
htmlFor
=
{
dropzoneId
}
role
=
"
button
"
tabIndex
=
{
0
}
onKeyDown
=
{
handleKeyDown
}
onDragOver
=
{
(
e
)
=>
{
e
.
preventDefault
(
)
;
setIsDragOver
(
true
)
;
}
}
onDragLeave
=
{
(
)
=>
setIsDragOver
(
false
)
}
onDrop
=
{
handleDrop
}
className
=
{
cn
(
"flex flex-col items-center justify-center p-8 border-2 border-dashed rounded-lg cursor-pointer"
,
"hover:border-primary focus:outline-none focus:ring-2 focus:ring-ring"
,
isDragOver
&&
"border-primary bg-primary/5"
,
error
&&
"border-destructive"
)
}
>
<
UploadIcon
className
=
"
h-10 w-10 text-muted-foreground mb-2
"
/>
<
span
className
=
"
text-sm font-medium
"
>
Drop files here or click to browse
</
span
>
<
span
className
=
"
text-xs text-muted-foreground mt-1
"
>
{
accept
&&
`
Accepted:
${
accept
}
`
}
{
maxSize
&&
`
(Max:
${
formatBytes
(
maxSize
)
}
)
`
}
</
span
>
</
label
>
{
error
&&
(
<
p
id
=
{
errorId
}
role
=
"
alert
"
className
=
"
text-sm text-destructive mt-2
"
>
{
error
}
</
p
>
)
}
</
div
>
)
;
}
Accessible Combobox (Searchable Select)
Concept
Native