init
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import { useRef } from "react"
|
||||
import type { AsyncOption, BaseFieldControlProps } from "../types"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxEmpty,
|
||||
} from "@/shared/components/ui/combobox"
|
||||
|
||||
const defaultGetOptionValue = (opt: any) => opt.value
|
||||
const defaultGetOptionLabel = (opt: any) => opt.label
|
||||
function defaultGetOptionKey(opt: any): string {
|
||||
const v = defaultGetOptionValue(opt)
|
||||
if (typeof v === "string" || typeof v === "number") return String(v)
|
||||
return String(opt.id ?? JSON.stringify(v))
|
||||
}
|
||||
|
||||
// ── Single-select ──
|
||||
|
||||
export type AsyncSelectFieldProps<TOption = AsyncOption> = BaseFieldControlProps<any> & {
|
||||
options: TOption[]
|
||||
loading?: boolean
|
||||
onInputValueChange?: (value: string) => void
|
||||
placeholder?: string
|
||||
getOptionValue?: (option: TOption) => any
|
||||
getOptionLabel?: (option: TOption) => string
|
||||
getOptionKey?: (option: TOption) => string
|
||||
}
|
||||
|
||||
export function AsyncSelectField<TOption = AsyncOption>({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled,
|
||||
invalid,
|
||||
options,
|
||||
loading,
|
||||
onInputValueChange,
|
||||
placeholder = "Search...",
|
||||
getOptionValue = defaultGetOptionValue,
|
||||
getOptionLabel = defaultGetOptionLabel,
|
||||
getOptionKey = defaultGetOptionKey,
|
||||
}: AsyncSelectFieldProps<TOption>) {
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
return (
|
||||
<div ref={anchorRef}>
|
||||
<Combobox
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val)}
|
||||
disabled={disabled}
|
||||
onInputValueChange={(val, { reason }) => {
|
||||
if (reason === "input-change") {
|
||||
onInputValueChange?.(val)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder={placeholder}
|
||||
showClear={!!value}
|
||||
onBlur={onBlur}
|
||||
aria-invalid={invalid || undefined}
|
||||
/>
|
||||
<ComboboxContent anchor={anchorRef}>
|
||||
<ComboboxList>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{!loading &&
|
||||
options.map((opt) => (
|
||||
<ComboboxItem key={getOptionKey(opt)} value={getOptionValue(opt)}>
|
||||
{getOptionLabel(opt)}
|
||||
</ComboboxItem>
|
||||
))}
|
||||
{!loading && options.length === 0 && (
|
||||
<ComboboxEmpty>No results found</ComboboxEmpty>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Multi-select ──
|
||||
|
||||
export type AsyncMultiSelectFieldProps<TOption = AsyncOption> = BaseFieldControlProps<any[]> & {
|
||||
options: TOption[]
|
||||
loading?: boolean
|
||||
onInputValueChange?: (value: string) => void
|
||||
placeholder?: string
|
||||
getOptionValue?: (option: TOption) => any
|
||||
getOptionLabel?: (option: TOption) => string
|
||||
getOptionKey?: (option: TOption) => string
|
||||
}
|
||||
|
||||
export function AsyncMultiSelectField<TOption = AsyncOption>({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled,
|
||||
invalid,
|
||||
options,
|
||||
loading,
|
||||
onInputValueChange,
|
||||
placeholder = "Search...",
|
||||
getOptionValue = defaultGetOptionValue,
|
||||
getOptionLabel = defaultGetOptionLabel,
|
||||
getOptionKey = defaultGetOptionKey,
|
||||
}: AsyncMultiSelectFieldProps<TOption>) {
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
return (
|
||||
<div ref={anchorRef}>
|
||||
<Combobox
|
||||
multiple
|
||||
value={value ?? []}
|
||||
onValueChange={(val) => onChange(val as any[])}
|
||||
disabled={disabled}
|
||||
onInputValueChange={(val, { reason }) => {
|
||||
if (reason === "input-change") {
|
||||
onInputValueChange?.(val)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder={placeholder}
|
||||
showClear={value && value.length > 0}
|
||||
onBlur={onBlur}
|
||||
aria-invalid={invalid || undefined}
|
||||
/>
|
||||
<ComboboxContent anchor={anchorRef}>
|
||||
<ComboboxList>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{!loading &&
|
||||
options.map((opt) => (
|
||||
<ComboboxItem key={getOptionKey(opt)} value={getOptionValue(opt)}>
|
||||
{getOptionLabel(opt)}
|
||||
</ComboboxItem>
|
||||
))}
|
||||
{!loading && options.length === 0 && (
|
||||
<ComboboxEmpty>No results found</ComboboxEmpty>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import type { BaseFieldControlProps } from "../types"
|
||||
import { Switch } from "@/shared/components/ui/switch"
|
||||
|
||||
export type CheckboxFieldProps = BaseFieldControlProps<boolean> & {
|
||||
label?: string
|
||||
}
|
||||
|
||||
export function CheckboxField({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
name,
|
||||
disabled,
|
||||
invalid,
|
||||
}: CheckboxFieldProps) {
|
||||
return (
|
||||
<Switch
|
||||
checked={value}
|
||||
onCheckedChange={(checked) => onChange(checked === true)}
|
||||
onBlur={onBlur}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
aria-invalid={invalid || undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { BaseFieldControlProps } from "../types"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
|
||||
export type FileInputFieldProps = BaseFieldControlProps<File | null> & {
|
||||
accept?: string
|
||||
}
|
||||
|
||||
export function FileInputField({
|
||||
// value intentionally unused — file inputs cannot be controlled
|
||||
onBlur,
|
||||
name,
|
||||
disabled,
|
||||
invalid,
|
||||
accept,
|
||||
onChange,
|
||||
}: FileInputFieldProps) {
|
||||
return (
|
||||
<Input
|
||||
type="file"
|
||||
accept={accept}
|
||||
onBlur={onBlur}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
aria-invalid={invalid || undefined}
|
||||
onChange={(e) => onChange(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import type { BaseFieldControlProps, SelectOption } from "../types"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
|
||||
export type SelectFieldProps = BaseFieldControlProps<string> & {
|
||||
placeholder?: string
|
||||
options: SelectOption[]
|
||||
}
|
||||
|
||||
export function SelectField({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
name,
|
||||
disabled,
|
||||
invalid,
|
||||
placeholder,
|
||||
options,
|
||||
}: SelectFieldProps) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange} disabled={disabled} name={name}>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
aria-invalid={invalid || undefined}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { BaseFieldControlProps } from "../types"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
|
||||
export type TextInputFieldProps = BaseFieldControlProps<string> & {
|
||||
placeholder?: string
|
||||
type?: React.HTMLInputTypeAttribute
|
||||
}
|
||||
|
||||
export function TextInputField({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
name,
|
||||
disabled,
|
||||
invalid,
|
||||
placeholder,
|
||||
type = "text",
|
||||
}: TextInputFieldProps) {
|
||||
return (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
aria-invalid={invalid || undefined}
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { BaseFieldControlProps } from "../types"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
|
||||
export type TextareaFieldProps = BaseFieldControlProps<string> & {
|
||||
placeholder?: string
|
||||
rows?: number
|
||||
}
|
||||
|
||||
export function TextareaField({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
name,
|
||||
disabled,
|
||||
invalid,
|
||||
placeholder,
|
||||
rows,
|
||||
}: TextareaFieldProps) {
|
||||
return (
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
aria-invalid={invalid || undefined}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user