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

253 lines
8.5 KiB
TypeScript

'use client'
import type { KeyboardEvent, ReactNode } from 'react'
import { useState } from 'react'
import { Controller, type Control } from 'react-hook-form'
import { Input, Textarea } from '@/components/heroui/Input'
import { NumberInput } from '@/components/heroui/NumberInput'
import { buildOutsideLabel, formatNumberInputDisplayValue } from '@/components/formElements/input/inputUtils'
import Button from '@/components/formElements/Button'
import EyeCrossedIcon from '@/components/icons/EyeCrossedIcon'
import EyeIcon from '@/components/icons/EyeIcon'
import { convertPersianToEnglish } from '@/helpers'
import { formatTomanInWords } from '@/helpers/numberToPersianWords'
import { texts } from '@/texts'
function isTomanAmountLabel(label?: string): boolean {
return typeof label === 'string' && label.includes(texts.common.toman)
}
function buildAmountDescription(value: unknown, label: string | undefined, existing?: ReactNode): ReactNode {
if (!isTomanAmountLabel(label)) return existing
const amountInWords = formatTomanInWords(value)
if (!amountInWords && !existing) return undefined
if (!amountInWords) return existing
if (!existing) return amountInWords
return (
<>
<span className="block">{amountInWords}</span>
<span className="mt-1 block">{existing}</span>
</>
)
}
interface Props {
ariaLabel?: string
autoFocus?: boolean
className?: string
control: Control
description?: ReactNode
direction: 'rtl' | 'ltr'
disabled?: boolean
englishDigitsOnly: boolean
finalInputWrapper: string
formatOptions?: Intl.NumberFormatOptions
iconEnd?: ReactNode
iconStart?: ReactNode
inputType?: 'text' | 'email' | 'number' | 'password' | 'tel'
isClearable: boolean
kind: 'input' | 'numberInput' | 'textarea'
label?: string
labelIcon?: ReactNode
maxValue?: number
minValue?: number
name: string
placeholder?: string
radius: 'none' | 'sm' | 'md' | 'lg' | 'full'
readonly?: boolean
required?: boolean
size?: 'sm' | 'md' | 'lg'
textAreaMinRows: number
variant: 'flat' | 'bordered' | 'faded' | 'underlined'
onBlur?: () => void
onClear?: () => void
onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void
}
export default function TextFieldControl(props: Props) {
const [passwordVisible, setPasswordVisible] = useState(false)
const { control, kind, name } = props
return (
<Controller
control={control}
name={name}
render={({ field, fieldState: { error } }) => {
const description = buildAmountDescription(field.value, props.label, props.description)
const common = {
autoFocus: props.autoFocus,
className: props.className,
description,
errorMessage: error?.message,
isDisabled: props.disabled,
isInvalid: !!error,
isReadOnly: props.readonly,
isRequired: false,
labelPlacement: 'outside' as const,
placeholder: props.placeholder,
radius: props.radius,
size: props.size,
validationBehavior: 'aria' as const,
variant: props.variant,
}
if (kind === 'textarea') {
const canClear = props.isClearable && !props.readonly
return (
<Textarea
{...field}
{...common}
classNames={{ inputWrapper: props.finalInputWrapper, label: 'labelClass' }}
isClearable={canClear}
label={buildOutsideLabel(props.label, null, props.required)}
minRows={props.textAreaMinRows}
onBlur={() => {
field.onBlur()
props.onBlur?.()
}}
{...(canClear
? {
onClear: () => {
field.onChange('')
},
}
: {})}
/>
)
}
if (kind === 'numberInput') {
const { onChange, value, ...fieldRest } = field
if (props.readonly) {
return (
<Input
{...fieldRest}
{...common}
isReadOnly
aria-label={props.ariaLabel}
classNames={{ innerWrapper: props.direction, inputWrapper: props.finalInputWrapper, label: 'labelClass' }}
endContent={props.iconEnd}
label={buildOutsideLabel(props.label, props.labelIcon ?? null, props.required)}
startContent={props.iconStart}
type="text"
value={formatNumberInputDisplayValue(value, props.formatOptions)}
/>
)
}
return (
<NumberInput
{...fieldRest}
{...common}
classNames={{ inputWrapper: props.finalInputWrapper, label: 'labelClass' }}
endContent={props.iconEnd}
formatOptions={props.formatOptions}
isClearable={props.isClearable}
label={buildOutsideLabel(props.label, props.labelIcon ?? null, props.required)}
maxValue={props.maxValue}
minValue={props.minValue}
startContent={props.iconStart}
value={value ?? undefined}
{...(props.isClearable
? {
onClear: () => {
if (props.onClear) {
props.onClear()
} else {
onChange(undefined)
}
},
}
: {})}
onValueChange={onChange}
/>
)
}
const isTel = props.inputType === 'tel'
// tel: LTR digit order. Alignment comes from styles/input.scss via
// :placeholder-shown (empty → right, typed → left). Do not set inline
// text-align — it fights the stylesheet when the DOM value updates first.
const canClear = props.isClearable && !props.readonly && props.inputType !== 'password' && !isTel
const isRtlLayout = props.direction === 'rtl' && !isTel
return (
<Input
{...field}
{...common}
aria-label={props.ariaLabel}
classNames={{
innerWrapper: isRtlLayout ? 'flex-row-reverse' : '',
input: isRtlLayout ? 'text-right' : isTel ? undefined : 'text-left',
inputWrapper: props.finalInputWrapper,
label: 'labelClass',
}}
dir={isTel ? 'ltr' : undefined}
endContent={
props.inputType === 'password' ? (
<Button
iconOnly
aria-label={passwordVisible ? texts.common.hidePassword : texts.common.showPassword}
className="rounded-full bg-transparent focus:outline-none"
color="default"
size="sm"
variant="light"
onClick={() => {
setPasswordVisible((value) => !value)
}}
>
{passwordVisible ? (
<EyeCrossedIcon className="size-4 text-text-light-25" />
) : (
<EyeIcon className="size-4 text-text-light-25" />
)}
</Button>
) : (
props.iconEnd
)
}
inputMode={isTel ? 'tel' : props.englishDigitsOnly ? 'numeric' : undefined}
isClearable={canClear}
label={buildOutsideLabel(props.label, props.labelIcon ?? null, props.required)}
max={props.maxValue}
min={props.minValue}
pattern={props.englishDigitsOnly ? '[0-9]*' : undefined}
startContent={props.iconStart}
type={passwordVisible ? 'text' : props.inputType}
onBlur={() => {
field.onBlur()
props.onBlur?.()
}}
onChange={(event) => {
let value = event.target.value
if (props.englishDigitsOnly || isTel) value = convertPersianToEnglish(value).replace(/\D/g, '')
else if (props.inputType === 'number') value = String(Number(value))
field.onChange(value)
}}
onKeyDown={props.onKeyDown}
{...(canClear
? {
onClear: () => {
if (props.onClear) {
props.onClear()
} else {
field.onChange('')
}
},
}
: {})}
/>
)
}}
/>
)
}