admin/components/consumer/input/ConsumerTextField.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

236 lines
7.8 KiB
TypeScript

'use client'
import type { KeyboardEvent } from 'react'
import { Controller, type Control } from 'react-hook-form'
import ConsumerFieldShell from '@/components/consumer/input/ConsumerFieldShell'
import type { ConsumerInputRadius, ConsumerInputSize, ConsumerInputTone } from '@/components/consumer/input/consumerInputTypes'
import {
CONSUMER_FIELD_INPUT_CLASS,
buildConsumerFieldWrapper,
resolveConsumerTone,
withConsumerFieldError,
} from '@/components/consumer/input/consumerInputStyles'
import { buildConsumerAmountDescription } from '@/components/consumer/input/consumerInputUtils'
import { Input } from '@/components/heroui/Input'
import { NumberInput } from '@/components/heroui/NumberInput'
import { formatNumberInputDisplayValue } from '@/components/formElements/input/inputUtils'
import { convertPersianToEnglish } from '@/helpers'
import { cn } from '@/lib/cn'
interface SharedProps {
ariaLabel?: string
autoFocus?: boolean
className?: string
radius: ConsumerInputRadius
size: ConsumerInputSize
control: Control
description?: React.ReactNode
direction: 'rtl' | 'ltr'
disabled?: boolean
englishDigitsOnly: boolean
formatOptions?: Intl.NumberFormatOptions
iconEnd?: React.ReactNode
iconStart?: React.ReactNode
inputWrapper?: string
inputType?: 'text' | 'email' | 'number' | 'password' | 'tel'
isClearable: boolean
label?: string
labelIcon?: React.ReactNode
maxValue?: number
minValue?: number
name: string
placeholder?: string
readonly?: boolean
required?: boolean
tone?: ConsumerInputTone
onBlur?: () => void
onClear?: () => void
onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void
}
export function ConsumerTextField(props: SharedProps) {
const tone = resolveConsumerTone('input', props.tone)
const wrapper = cn(
buildConsumerFieldWrapper({
radius: props.radius,
size: props.size,
tone,
}),
props.inputWrapper
)
return (
<Controller
control={props.control}
name={props.name}
render={({ field, fieldState: { error } }) => {
const invalid = !!error
const inputWrapper = invalid ? withConsumerFieldError(wrapper) : wrapper
const description = buildConsumerAmountDescription(field.value, props.label, props.description)
const isTel = props.inputType === 'tel'
const canClear = props.isClearable && !props.readonly && props.inputType !== 'password' && !isTel
const isRtlLayout = props.direction === 'rtl' && !isTel
return (
<ConsumerFieldShell
className={props.className}
description={description}
errorMessage={error?.message}
invalid={invalid}
label={props.label}
labelIcon={props.labelIcon}
required={props.required}
>
<Input
{...field}
aria-label={props.label ? undefined : props.ariaLabel}
autoFocus={props.autoFocus}
classNames={{
innerWrapper: isRtlLayout ? 'flex-row-reverse' : '',
input: cn(CONSUMER_FIELD_INPUT_CLASS, isRtlLayout ? 'text-right' : isTel ? undefined : 'text-left'),
inputWrapper,
}}
dir={isTel ? 'ltr' : undefined}
endContent={props.iconEnd}
inputMode={isTel ? 'tel' : props.englishDigitsOnly ? 'numeric' : undefined}
isClearable={canClear}
isDisabled={props.disabled}
isInvalid={invalid}
isReadOnly={props.readonly}
max={props.maxValue}
min={props.minValue}
pattern={props.englishDigitsOnly ? '[0-9]*' : undefined}
placeholder={props.placeholder}
size={props.size}
startContent={props.iconStart}
type={props.inputType}
validationBehavior="aria"
variant="bordered"
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('')
},
}
: {})}
/>
</ConsumerFieldShell>
)
}}
/>
)
}
export function ConsumerNumberField(props: SharedProps) {
const tone = resolveConsumerTone('numberInput', props.tone)
const wrapper = cn(
buildConsumerFieldWrapper({
radius: props.radius,
size: props.size,
tone,
}),
props.inputWrapper
)
return (
<Controller
control={props.control}
name={props.name}
render={({ field, fieldState: { error } }) => {
const invalid = !!error
const inputWrapper = invalid ? withConsumerFieldError(wrapper) : wrapper
const description = buildConsumerAmountDescription(field.value, props.label, props.description)
const { onChange, value, ...fieldRest } = field
if (props.readonly) {
return (
<ConsumerFieldShell
className={props.className}
description={description}
errorMessage={error?.message}
invalid={invalid}
label={props.label}
labelIcon={props.labelIcon}
required={props.required}
>
<Input
{...fieldRest}
isReadOnly
aria-label={props.ariaLabel}
classNames={{
innerWrapper: props.direction,
input: CONSUMER_FIELD_INPUT_CLASS,
inputWrapper,
}}
endContent={props.iconEnd}
isDisabled={props.disabled}
isInvalid={invalid}
placeholder={props.placeholder}
size={props.size}
startContent={props.iconStart}
type="text"
validationBehavior="aria"
value={formatNumberInputDisplayValue(value, props.formatOptions)}
variant="bordered"
/>
</ConsumerFieldShell>
)
}
return (
<ConsumerFieldShell
className={props.className}
description={description}
errorMessage={error?.message}
invalid={invalid}
label={props.label}
labelIcon={props.labelIcon}
required={props.required}
>
<NumberInput
{...fieldRest}
classNames={{ input: CONSUMER_FIELD_INPUT_CLASS, inputWrapper }}
endContent={props.iconEnd}
formatOptions={props.formatOptions}
isClearable={props.isClearable}
isDisabled={props.disabled}
isInvalid={invalid}
maxValue={props.maxValue}
minValue={props.minValue}
size={props.size}
startContent={props.iconStart}
validationBehavior="aria"
value={value ?? undefined}
variant="bordered"
{...(props.isClearable
? {
onClear: () => {
if (props.onClear) props.onClear()
else onChange(undefined)
},
}
: {})}
onValueChange={onChange}
/>
</ConsumerFieldShell>
)
}}
/>
)
}