Skip to content

Commit 7b93964

Browse files
New forms field types
1 parent 9db9a75 commit 7b93964

File tree

1 file changed

+249
-7
lines changed

1 file changed

+249
-7
lines changed

app/components/forms.tsx

Lines changed: 249 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,40 @@
1-
import { useInputControl } from '@conform-to/react'
1+
import { FieldMetadata, useInputControl } from '@conform-to/react'
22
import { REGEXP_ONLY_DIGITS_AND_CHARS, type OTPInputProps } from 'input-otp'
3-
import React, { useId } from 'react'
4-
import { Checkbox, type CheckboxProps } from './ui/checkbox.tsx'
3+
import React, { useId, useState } from 'react'
4+
import { Checkbox, type CheckboxProps } from './ui/checkbox'
55
import {
66
InputOTP,
77
InputOTPGroup,
88
InputOTPSeparator,
99
InputOTPSlot,
10-
} from './ui/input-otp.tsx'
11-
import { Input } from './ui/input.tsx'
12-
import { Label } from './ui/label.tsx'
13-
import { Textarea } from './ui/textarea.tsx'
10+
} from './ui/input-otp'
11+
import { Input } from './ui/input'
12+
import { Label } from './ui/label'
13+
import { Button } from './ui/button'
14+
import {
15+
Select,
16+
SelectContent,
17+
SelectItem,
18+
SelectTrigger,
19+
SelectValue,
20+
} from './ui/select'
21+
import { Textarea } from './ui/textarea'
22+
import {
23+
Command,
24+
CommandEmpty,
25+
CommandGroup,
26+
CommandInput,
27+
CommandItem,
28+
CommandList,
29+
CommandDialog,
30+
} from '#app/components/ui/command'
31+
import {
32+
Popover,
33+
PopoverContent,
34+
PopoverTrigger,
35+
} from '#app/components/ui/popover'
36+
import { Icon } from '#app/components/ui/icon'
37+
import { cn } from '#app/utils/misc'
1438

1539
export type ListOfErrors = Array<string | null | undefined> | null | undefined
1640

@@ -48,6 +72,7 @@ export function Field({
4872
const fallbackId = useId()
4973
const id = inputProps.id ?? fallbackId
5074
const errorId = errors?.length ? `${id}-error` : undefined
75+
5176
return (
5277
<div className={className}>
5378
<Label htmlFor={id} {...labelProps} />
@@ -64,6 +89,58 @@ export function Field({
6489
)
6590
}
6691

92+
export function SelectField({
93+
labelProps,
94+
selectProps,
95+
selectOptions,
96+
errors,
97+
className,
98+
}: {
99+
labelProps: React.LabelHTMLAttributes<HTMLLabelElement>
100+
selectProps: {
101+
name?: string
102+
placeholder?: string
103+
disabled?: boolean
104+
onValueChange?: (value: string) => void
105+
}
106+
selectOptions?: { value: string; label: string }[]
107+
errors?: ListOfErrors
108+
className?: string
109+
}) {
110+
const fallbackId = useId()
111+
const id = selectProps.name ?? fallbackId
112+
const errorId = errors?.length ? `${id}-error` : undefined
113+
const placeholder = selectProps?.placeholder || 'Select'
114+
115+
return (
116+
<div className={className}>
117+
<Label htmlFor={id} {...labelProps} />
118+
<Select {...selectProps}>
119+
<SelectTrigger
120+
aria-invalid={errorId ? true : undefined}
121+
aria-describedby={errorId}
122+
>
123+
<SelectValue placeholder={placeholder} />
124+
</SelectTrigger>
125+
<SelectContent>
126+
{selectOptions?.map((option) => (
127+
<SelectItem
128+
key={`${id}-${option.value}`}
129+
value={option.value}
130+
className="hover:bg-primary/10"
131+
>
132+
{option.label}
133+
</SelectItem>
134+
))}
135+
</SelectContent>
136+
</Select>
137+
<div className="min-h-[32px] px-4 pb-3 pt-1">
138+
{errorId ? <ErrorList id={errorId} errors={errors} /> : null}
139+
</div>
140+
</div>
141+
)
142+
}
143+
67144
export function OTPField({
68145
labelProps,
69146
inputProps,
@@ -122,6 +199,7 @@ export function TextareaField({
122199
const fallbackId = useId()
123200
const id = textareaProps.id ?? textareaProps.name ?? fallbackId
124201
const errorId = errors?.length ? `${id}-error` : undefined
202+
125203
return (
126204
<div className={className}>
127205
<Label htmlFor={id} {...labelProps} />
@@ -200,3 +278,167 @@ export function CheckboxField({
200278
</div>
201279
)
202280
}
281+
282+
// Add an input for the type="time" input. Should model the Field component.
283+
export function TimeField({
284+
labelProps,
285+
inputProps,
286+
errors,
287+
className,
288+
}: {
289+
labelProps: React.LabelHTMLAttributes<HTMLLabelElement>
290+
inputProps: React.InputHTMLAttributes<HTMLInputElement>
291+
errors?: ListOfErrors
292+
className?: string
293+
}) {
294+
const fallbackId = useId()
295+
const id = inputProps.id ?? fallbackId
296+
const errorId = errors?.length ? `${id}-error` : undefined
297+
298+
return (
299+
<div className={className}>
300+
<Label htmlFor={id} {...labelProps} />
301+
<Input
302+
id={id}
303+
type="time"
304+
aria-invalid={errorId ? true : undefined}
305+
aria-describedby={errorId}
306+
{...inputProps}
307+
/>
308+
<div className="min-h-[32px] px-4 pb-3 pt-1">
309+
{errorId ? <ErrorList id={errorId} errors={errors} /> : null}
310+
</div>
311+
</div>
312+
)
313+
}
314+
315+
export function NumberField({
316+
labelProps,
317+
inputProps,
318+
errors,
319+
className,
320+
}: {
321+
labelProps: React.LabelHTMLAttributes<HTMLLabelElement>
322+
inputProps: React.InputHTMLAttributes<HTMLInputElement>
323+
errors?: ListOfErrors
324+
className?: string
325+
}) {
326+
const fallbackId = useId()
327+
const id = inputProps.id ?? fallbackId
328+
const errorId = errors?.length ? `${id}-error` : undefined
329+
330+
return (
331+
<div className={className}>
332+
<Label htmlFor={id} {...labelProps} />
333+
<Input
334+
id={id}
335+
type="number"
336+
aria-invalid={errorId ? true : undefined}
337+
aria-describedby={errorId}
338+
{...inputProps}
339+
/>
340+
<div className="min-h-[32px] px-4 pb-3 pt-1">
341+
{errorId ? <ErrorList id={errorId} errors={errors} /> : null}
342+
</div>
343+
</div>
344+
)
345+
}
346+
347+
export function ComboboxField({
348+
labelProps,
349+
buttonProps,
350+
comboboxProps,
351+
comboboxOptions,
352+
errors,
353+
className,
354+
field,
355+
}: {
356+
labelProps: React.LabelHTMLAttributes<HTMLLabelElement>
357+
buttonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>
358+
comboboxProps: {
359+
name: string
360+
placeholder?: string
361+
emptyText?: string | JSX.Element
362+
}
363+
comboboxOptions: { value: string; label: string }[]
364+
errors?: ListOfErrors
365+
className?: string
366+
field: FieldMetadata<string>
367+
}) {
368+
const [open, setOpen] = useState(false)
369+
const control = useInputControl(field)
370+
const fallbackId = useId()
371+
const id = comboboxProps.name ?? fallbackId
372+
const errorId = errors?.length ? `${id}-error` : undefined
373+
374+
return (
375+
<div className={className}>
376+
<Label htmlFor={id} {...labelProps} />
377+
<Popover open={open} onOpenChange={setOpen}>
378+
<PopoverTrigger asChild>
379+
<Button
380+
{...buttonProps}
381+
variant="outline"
382+
role="combobox"
383+
aria-expanded={open}
384+
aria-invalid={errorId ? true : undefined}
385+
aria-describedby={errorId}
386+
className="w-full justify-between"
387+
>
388+
{control.value
389+
? comboboxOptions.find((option) => option.value === control.value)
390+
?.label
391+
: comboboxProps.placeholder || 'Select...'}
392+
<Icon name="caret-sort" className="ml-2 shrink-0" />
393+
</Button>
394+
</PopoverTrigger>
395+
396+
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
397+
<Command
398+
filter={(value, search) => {
399+
const item = comboboxOptions.find((item) => item.value === value)
400+
if (!item) return 0
401+
if (item.label.toLowerCase().includes(search.toLowerCase()))
402+
return 1
403+
404+
return 0
405+
}}
406+
>
407+
<CommandInput placeholder="Search..." />
408+
<CommandList>
409+
<CommandEmpty>
410+
{comboboxProps.emptyText || 'No options found.'}
411+
</CommandEmpty>
412+
<CommandGroup>
413+
{comboboxOptions.map((option) => (
414+
<CommandItem
415+
key={option.value}
416+
value={option.value}
417+
onSelect={(currentValue) => {
418+
control.change(currentValue)
419+
setOpen(false)
420+
}}
421+
>
422+
<Icon
423+
name="check"
424+
className={cn(
425+
'mr-2 h-4 w-4',
426+
control.value === option.value
427+
? 'opacity-100'
428+
: 'opacity-0',
429+
)}
430+
/>
431+
{option.label}
432+
</CommandItem>
433+
))}
434+
</CommandGroup>
435+
</CommandList>
436+
</Command>
437+
</PopoverContent>
438+
</Popover>
439+
<div className="min-h-[32px] px-4 pb-3 pt-1">
440+
{errorId ? <ErrorList id={errorId} errors={errors} /> : null}
441+
</div>
442+
</div>
443+
)
444+
}

0 commit comments

Comments
 (0)