admin/components/formElements/Input.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

386 lines
11 KiB
TypeScript

'use client'
import type { PropsWithChildren } from 'react'
import { useCallback, useEffect } from 'react'
import { FormProvider, useForm, useFormContext, type UseFormReturn } from 'react-hook-form'
import dynamic from 'next/dynamic'
import type { CustomInputProps, InputKind } from '@/components/formElements/input/inputTypes'
import { useRemoteSelectOptions } from '@/components/formElements/input/useRemoteSelectOptions'
import TextFieldControl from '@/components/formElements/input/TextFieldControl'
import ChoiceFieldControl from '@/components/formElements/input/ChoiceFieldControl'
import { texts, format } from '@/texts'
import useWindowSize from '@/hooks/useWindowSize'
const DateFieldControl = dynamic(() => import('@/components/formElements/input/DateFieldControl'), { ssr: false })
const EventScheduleFieldControl = dynamic(() => import('@/components/events/schedule/EventScheduleFieldControl'), {
ssr: false,
})
const JalaliBirthDateFieldControl = dynamic(() => import('@/components/formElements/input/JalaliBirthDateFieldControl'), {
ssr: false,
})
function defaultValueForKind(kind: InputKind): unknown {
if (kind === 'checkbox' || kind === 'switch') return false
if (kind === 'numberInput') return undefined
return ''
}
function useOptionalFormContext(): UseFormReturn | null {
const context = useFormContext() as UseFormReturn | null
return context?.control ? context : null
}
type BoundProps = PropsWithChildren<CustomInputProps>
function FormBoundInput({
generalType,
inputType,
variant = 'bordered',
label,
name,
placeholder: outerPlaceholder,
description,
className,
iconStart,
iconEnd,
selectKey = 'code',
selectValue = 'name',
filterName = 'title',
filterValue = '',
apiField = 'data',
searchMode = 'api',
direction = 'rtl',
pageSize = 20,
otpLength = 4,
url,
size,
radius = 'md',
autoFocus,
disabled,
required,
readonly,
eventPickerStyle = 'default',
multiple,
onlyYearPicker,
minValue,
maxValue,
formatOptions,
maxDate,
minDate = '1300/1/1',
textAreaMinRows = 5,
selectOptions,
radioOptions,
isOutsideFilter,
ariaLabel,
inputWrapper,
labelIcon = null,
isClearable = true,
englishDigitsOnly = false,
onBlur,
onClear,
onKeyDown,
orientation = 'horizontal',
}: BoundProps) {
const { width } = useWindowSize()
const { control, getValues } = useFormContext()
const defaultInputWrapper =
variant === 'bordered'
? // InputGroup focuses via :focus-within; TextArea via :focus / data-focused.
// Invalid: single red border only (global CSS kills HeroUI invalid-field-ring outline).
'shadow-none border hover:!border-primary-300 focus:!border-primary focus-within:!border-primary data-[focused=true]:!border-primary data-[focus-within=true]:!border-primary data-[invalid=true]:!border-fourth data-[invalid=true]:hover:!border-fourth data-[invalid=true]:focus-within:!border-fourth'
: 'shadow-none'
const finalInputWrapper = inputWrapper || defaultInputWrapper
const isChoiceField = generalType === 'select' || generalType === 'combobox'
const placeholder =
outerPlaceholder ?? (isChoiceField ? undefined : typeof label === 'string' ? format(texts.common.enterField, { label }) : '')
const getCurrentValue = useCallback(() => getValues(name), [getValues, name])
const {
loading,
options: searchOptions,
search: handleComboboxChange,
} = useRemoteSelectOptions({
apiField,
disabled,
enabled: generalType === 'combobox',
filterName,
filterValue,
getCurrentValue,
isOutsideFilter,
pageSize,
searchMode,
selectKey,
selectValue,
url,
})
switch (generalType) {
case 'input':
case 'numberInput':
return (
<TextFieldControl
ariaLabel={ariaLabel}
autoFocus={autoFocus}
className={className}
control={control}
description={description}
direction={direction}
disabled={disabled}
englishDigitsOnly={englishDigitsOnly}
finalInputWrapper={finalInputWrapper}
formatOptions={formatOptions}
iconEnd={iconEnd}
iconStart={iconStart}
inputType={inputType}
isClearable={isClearable}
kind={generalType}
label={label}
labelIcon={labelIcon}
maxValue={maxValue}
minValue={minValue}
name={name}
placeholder={placeholder}
radius={radius}
readonly={readonly}
required={required}
size={size}
textAreaMinRows={textAreaMinRows}
variant={variant}
onBlur={onBlur}
onClear={onClear}
onKeyDown={onKeyDown}
/>
)
case 'otp':
case 'combobox':
return (
<ChoiceFieldControl
ariaLabel={ariaLabel}
autoFocus={autoFocus}
className={className}
control={control}
description={description}
disabled={disabled}
finalInputWrapper={finalInputWrapper}
getValues={getValues}
iconEnd={iconEnd}
iconStart={iconStart}
inputType={inputType}
kind={generalType}
label={label}
loading={loading}
multiple={multiple}
name={name}
options={selectOptions ?? []}
orientation={orientation}
otpLength={otpLength}
placeholder={placeholder}
radius={radius}
readonly={readonly}
required={required}
searchOptions={searchOptions}
selectKey={selectKey}
selectValue={selectValue}
size={size}
variant={variant}
onSearch={handleComboboxChange}
/>
)
case 'textarea':
return (
<TextFieldControl
ariaLabel={ariaLabel}
autoFocus={autoFocus}
className={className}
control={control}
description={description}
direction={direction}
disabled={disabled}
englishDigitsOnly={englishDigitsOnly}
finalInputWrapper={finalInputWrapper}
iconEnd={iconEnd}
iconStart={iconStart}
isClearable={isClearable}
kind="textarea"
label={label}
name={name}
placeholder={placeholder}
radius={radius}
readonly={readonly}
required={required}
size={size}
textAreaMinRows={textAreaMinRows}
variant={variant}
onBlur={onBlur}
/>
)
case 'select':
case 'radio':
case 'checkbox':
case 'switch':
return (
<ChoiceFieldControl
ariaLabel={ariaLabel}
autoFocus={autoFocus}
className={className}
control={control}
description={description}
disabled={disabled}
finalInputWrapper={finalInputWrapper}
getValues={getValues}
iconEnd={iconEnd}
iconStart={iconStart}
inputType={inputType}
kind={generalType}
label={label}
loading={loading}
multiple={multiple}
name={name}
options={generalType === 'radio' ? (radioOptions ?? []) : (selectOptions ?? [])}
orientation={orientation}
otpLength={otpLength}
placeholder={placeholder}
radius={radius}
readonly={readonly}
required={required}
searchOptions={searchOptions}
selectKey={selectKey}
selectValue={selectValue}
size={size}
variant={variant}
onSearch={handleComboboxChange}
/>
)
case 'datePickerPro':
case 'datePicker':
case 'timePicker':
return (
<DateFieldControl
autoFocus={autoFocus}
className={className}
control={control}
description={description}
disabled={disabled}
finalInputWrapper={finalInputWrapper}
kind={generalType}
label={label}
labelIcon={labelIcon}
maxDate={maxDate}
minDate={minDate}
name={name}
onlyYearPicker={onlyYearPicker}
placeholder={placeholder}
radius={radius}
readonly={readonly}
required={required}
size={size}
variant={variant}
viewportWidth={width}
/>
)
case 'eventDatePicker':
case 'eventTimePicker':
return (
<EventScheduleFieldControl
className={className}
control={control}
description={description}
disabled={disabled}
kind={generalType}
label={label}
minDate={minDate}
minValue={minValue}
name={name}
placeholder={placeholder}
readonly={readonly}
required={required}
size={size}
triggerStyle={eventPickerStyle}
variant={variant}
/>
)
case 'birthDatePicker':
return (
<JalaliBirthDateFieldControl
className={className}
control={control}
description={description}
disabled={disabled}
label={label}
name={name}
placeholder={placeholder}
readonly={readonly}
required={required}
size={size}
variant={variant}
/>
)
default:
return null
}
}
/**
* Standalone / controlled bridge: keeps one public Input API without requiring
* the caller to set up FormProvider for simple local state.
*/
function StandaloneInput(props: BoundProps) {
const { name, generalType, value, onValueChange } = props
const methods = useForm({
defaultValues: { [name]: value ?? defaultValueForKind(generalType) },
values: value !== undefined ? { [name]: value } : undefined,
})
useEffect(() => {
if (!onValueChange) return
const subscription = methods.watch((values, info) => {
if (info.name && info.name !== name) return
onValueChange(values[name])
})
return () => {
subscription.unsubscribe()
}
}, [methods, name, onValueChange])
return (
<FormProvider {...methods}>
<FormBoundInput {...props} />
</FormProvider>
)
}
/**
* App-facing form field. Prefer this over `@/components/heroui/*` controls.
* - Inside FormProvider: binds via `name` (react-hook-form)
* - Outside FormProvider (or with `value` / `onValueChange`): works standalone
*/
const Input = (props: BoundProps) => {
const form = useOptionalFormContext()
const isControlled = props.value !== undefined || props.onValueChange !== undefined
// Outside FormProvider, or explicitly controlled from parent state → standalone bridge.
if (!form || isControlled) {
return <StandaloneInput {...props} />
}
return <FormBoundInput {...props} />
}
export default Input