admin/components/formElements/input/ChoiceFieldControl.tsx
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

299 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import type { ReactNode } from 'react'
import { Controller, type Control } from 'react-hook-form'
import { Autocomplete, AutocompleteItem } from '@/components/heroui/Autocomplete'
import { Checkbox, Radio, RadioGroup, Switch } from '@/components/heroui/SelectionControls'
import { InputOtp } from '@/components/heroui/InputOtp'
import { Select, SelectItem } from '@/components/heroui/Select'
import { cn } from '@/lib/cn'
import { texts } from '@/texts'
import { buildOutsideLabel, getOptionValue, type SelectOptionItem } from '@/components/formElements/input/inputUtils'
import { coerceToString } from '@/helpers'
import { useDialogPortalContainer } from '@/components/formElements/input/useDialogPortalContainer'
type Kind = 'otp' | 'combobox' | 'select' | 'radio' | 'checkbox' | 'switch'
interface Props {
ariaLabel?: string
autoFocus?: boolean
className?: string
control: Control
description?: ReactNode
disabled?: boolean
finalInputWrapper: string
getValues: (name: string) => unknown
iconEnd?: ReactNode
iconStart?: ReactNode
inputType?: 'text' | 'email' | 'number' | 'password' | 'tel'
kind: Kind
label?: string
loading: boolean
multiple?: boolean
name: string
options: SelectOptionItem[]
orientation: 'horizontal' | 'vertical'
otpLength: number
placeholder?: string
radius: 'none' | 'sm' | 'md' | 'lg' | 'full'
readonly?: boolean
required?: boolean
searchOptions: SelectOptionItem[]
selectKey: string
selectValue: string
size?: 'sm' | 'md' | 'lg'
variant: 'flat' | 'bordered' | 'faded' | 'underlined'
onSearch: (value: string) => void
}
function OutsideLabel({ invalid, label, required }: { invalid: boolean; label?: string; required?: boolean }) {
if (!label) return null
return <span className={cn('labelClass mb-1 block', invalid && '!text-fourth')}>{buildOutsideLabel(label, null, required)}</span>
}
export default function ChoiceFieldControl(props: Props) {
const { portalContainer, setHostEl } = useDialogPortalContainer()
return (
<Controller
control={props.control}
name={props.name}
render={({ field, fieldState: { error } }) => {
const invalid = !!error
const ariaLabel = props.ariaLabel ?? props.label ?? props.name
if (props.kind === 'otp') {
return (
<div className="relative flex flex-col">
<div className="labelClass">{buildOutsideLabel(props.label, null, props.required)}</div>
<InputOtp
{...field}
fullWidth
autoComplete="one-time-code"
autoFocus={props.autoFocus}
className={props.className}
classNames={{ segmentWrapper: 'flex-row-reverse' }}
description={props.description}
errorMessage={error?.message}
inputMode="numeric"
isDisabled={props.disabled}
isInvalid={invalid}
isReadOnly={props.readonly}
isRequired={props.required}
length={props.otpLength}
placeholder={props.placeholder}
radius={props.radius}
size={props.size}
validationBehavior="aria"
variant={props.variant}
/>
</div>
)
}
if (props.kind === 'combobox') {
return (
<div
ref={setHostEl}
className={cn('flex flex-col', props.className)}
>
<OutsideLabel
invalid={invalid}
label={props.label}
required={props.required}
/>
<Autocomplete
{...field}
aria-label={ariaLabel}
autoFocus={props.autoFocus}
className="w-full"
defaultItems={props.searchOptions}
description={props.description}
endContent={props.iconEnd}
errorMessage={error?.message}
isDisabled={props.disabled}
isInvalid={invalid}
isLoading={props.loading}
isReadOnly={props.readonly}
listboxProps={{ emptyContent: texts.common.emptyChoice }}
placeholder={props.placeholder}
popoverProps={{
placement: 'bottom-start',
portalContainer,
shouldBlockScroll: false,
classNames: { content: 'max-h-[300px] overflow-y-auto' },
}}
radius={props.radius}
selectedKey={field.value}
size={props.size}
startContent={props.iconStart}
type={props.inputType}
validationBehavior="aria"
variant={props.variant}
onInputChange={props.onSearch}
onSelectionChange={(value) => {
if (props.multiple) {
const current = props.getValues(field.name)
field.onChange([...(Array.isArray(current) ? current : []), value])
} else field.onChange(value)
}}
>
{(item) => (
<AutocompleteItem
key={coerceToString(getOptionValue(item, props.selectKey))}
textValue={String(getOptionValue(item, props.selectValue) || getOptionValue(item, props.selectKey))}
>
{coerceToString(getOptionValue(item, props.selectValue))}
</AutocompleteItem>
)}
</Autocomplete>
</div>
)
}
if (props.kind === 'select') {
const selectedKeys = props.multiple
? field.value
: field.value !== undefined && field.value !== null && field.value !== ''
? [String(field.value)]
: []
return (
<div
ref={setHostEl}
className={cn('flex flex-col', props.className)}
>
<OutsideLabel
invalid={invalid}
label={props.label}
required={props.required}
/>
<Select
ref={field.ref}
aria-label={ariaLabel}
autoFocus={props.autoFocus}
className="w-full"
classNames={{ trigger: props.finalInputWrapper }}
description={props.description}
endContent={props.iconEnd}
errorMessage={error?.message}
isDisabled={props.disabled}
isInvalid={invalid}
name={field.name}
placeholder={props.placeholder}
popoverProps={{
placement: 'bottom-start',
portalContainer,
shouldBlockScroll: false,
classNames: { content: 'max-h-[300px] overflow-y-auto' },
}}
radius={props.radius}
selectedKeys={selectedKeys}
selectionMode={props.multiple ? 'multiple' : 'single'}
size={props.size}
startContent={props.iconStart}
validationBehavior="aria"
variant={props.variant}
onBlur={field.onBlur}
onSelectionChange={(keys) => {
if (props.multiple) field.onChange(keys)
else if (keys === 'all') field.onChange('')
else field.onChange(String(Array.from(keys as Set<React.Key>)[0] ?? ''))
}}
>
{props.options.map((item, index) => {
const optionKey = coerceToString(getOptionValue(item, props.selectKey)) || `select-item-${index}`
const optionLabel = coerceToString(getOptionValue(item, props.selectValue))
const depth = Number(getOptionValue(item, 'depth') ?? 0)
return (
<SelectItem
key={optionKey}
textValue={optionLabel}
>
{depth > 0 ? (
<div
className="flex items-center gap-2"
style={{ paddingRight: depth * 20 }}
>
<span className="select-none text-tertiary-300"></span>
<span className="truncate">{optionLabel}</span>
</div>
) : (
optionLabel
)}
</SelectItem>
)
})}
</Select>
</div>
)
}
if (props.kind === 'radio') {
return (
<RadioGroup
{...field}
className={props.className}
classNames={{ label: `labelClass ${invalid ? '!text-fourth' : 'text-secondary-10'}` }}
description={props.description}
errorMessage={error?.message}
isDisabled={props.disabled}
isInvalid={invalid}
isReadOnly={props.readonly}
isRequired={props.required}
label={props.label}
orientation={props.orientation}
size={props.size}
validationBehavior="aria"
value={field.value}
onValueChange={field.onChange}
>
{props.options.map((item) => (
<Radio
key={coerceToString(getOptionValue(item, props.selectKey) ?? getOptionValue(item, 'name'))}
value={coerceToString(getOptionValue(item, props.selectKey))}
>
{coerceToString(getOptionValue(item, 'name'))}
</Radio>
))}
</RadioGroup>
)
}
if (props.kind === 'checkbox') {
return (
<Checkbox
{...field}
className={props.className}
classNames={{ label: 'labelClass' }}
isDisabled={props.disabled}
isInvalid={invalid}
isReadOnly={props.readonly}
isSelected={field.value}
size={props.size}
onValueChange={field.onChange}
>
{props.label}
</Checkbox>
)
}
return (
<Switch
className={props.className}
classNames={{ label: 'labelClass' }}
isDisabled={props.disabled}
isSelected={field.value}
name={field.name}
size={props.size}
onBlur={field.onBlur}
onValueChange={field.onChange}
>
{props.label}
</Switch>
)
}}
/>
)
}