Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m54s
178 lines
5.2 KiB
React
178 lines
5.2 KiB
React
import { useState, useEffect, useRef } from 'react'
|
||
import { IconCheck, IconX, IconAlertCircle } from '@tabler/icons-react'
|
||
|
||
/**
|
||
* ValidatedInput - input с real-time валидацией
|
||
* @param {function} validate - функция валидации, возвращает { valid: boolean, message: string }
|
||
* @param {number} debounce - задержка перед валидацией в ms
|
||
*/
|
||
function ValidatedInput({
|
||
value,
|
||
onChange,
|
||
validate,
|
||
debounce = 300,
|
||
label,
|
||
placeholder,
|
||
hint,
|
||
required = false,
|
||
type = 'text',
|
||
className = '',
|
||
showValidIcon = true,
|
||
validateOnChange = true,
|
||
...props
|
||
}) {
|
||
const [validationState, setValidationState] = useState({
|
||
valid: null,
|
||
message: '',
|
||
isValidating: false
|
||
})
|
||
const [isTouched, setIsTouched] = useState(false)
|
||
const timeoutRef = useRef(null)
|
||
|
||
useEffect(() => {
|
||
// Не валидируем, пока пользователь не начал вводить
|
||
if (!isTouched || !validateOnChange) return
|
||
if (!validate) {
|
||
setValidationState({ valid: null, message: '', isValidating: false })
|
||
return
|
||
}
|
||
|
||
// Очищаем предыдущий таймер
|
||
if (timeoutRef.current) {
|
||
clearTimeout(timeoutRef.current)
|
||
}
|
||
|
||
// Показываем индикатор валидации
|
||
setValidationState(prev => ({ ...prev, isValidating: true }))
|
||
|
||
// Запускаем валидацию с debounce
|
||
timeoutRef.current = setTimeout(async () => {
|
||
try {
|
||
const result = validate(value)
|
||
|
||
// Поддержка как синхронной, так и асинхронной валидации
|
||
const validationResult = result instanceof Promise ? await result : result
|
||
|
||
setValidationState({
|
||
valid: validationResult.valid,
|
||
message: validationResult.message || '',
|
||
isValidating: false
|
||
})
|
||
} catch (error) {
|
||
setValidationState({
|
||
valid: false,
|
||
message: 'Ошибка валидации',
|
||
isValidating: false
|
||
})
|
||
}
|
||
}, debounce)
|
||
|
||
return () => {
|
||
if (timeoutRef.current) {
|
||
clearTimeout(timeoutRef.current)
|
||
}
|
||
}
|
||
}, [value, validate, debounce, isTouched, validateOnChange])
|
||
|
||
const handleChange = (e) => {
|
||
if (!isTouched) setIsTouched(true)
|
||
onChange(e)
|
||
}
|
||
|
||
const handleBlur = () => {
|
||
setIsTouched(true)
|
||
// Запускаем валидацию сразу при потере фокуса
|
||
if (validate && !validateOnChange) {
|
||
const result = validate(value)
|
||
const validationResult = result instanceof Promise ? result.then(r => {
|
||
setValidationState({
|
||
valid: r.valid,
|
||
message: r.message || '',
|
||
isValidating: false
|
||
})
|
||
}) : setValidationState({
|
||
valid: result.valid,
|
||
message: result.message || '',
|
||
isValidating: false
|
||
})
|
||
}
|
||
}
|
||
|
||
const getInputClass = () => {
|
||
const classes = ['form-control']
|
||
|
||
if (isTouched && validationState.valid === true) {
|
||
classes.push('is-valid')
|
||
} else if (isTouched && validationState.valid === false) {
|
||
classes.push('is-invalid')
|
||
}
|
||
|
||
return classes.join(' ')
|
||
}
|
||
|
||
return (
|
||
<div className={`mb-3 ${className}`}>
|
||
{label && (
|
||
<label className="form-label">
|
||
{label}
|
||
{required && <span className="text-danger ms-1">*</span>}
|
||
</label>
|
||
)}
|
||
|
||
<div className="input-group">
|
||
<input
|
||
type={type}
|
||
className={getInputClass()}
|
||
value={value}
|
||
onChange={handleChange}
|
||
onBlur={handleBlur}
|
||
placeholder={placeholder}
|
||
aria-invalid={validationState.valid === false}
|
||
aria-describedby={`${props.id}-feedback ${props.id}-hint`}
|
||
{...props}
|
||
/>
|
||
|
||
{/* Индикатор валидации */}
|
||
{showValidIcon && isTouched && !validationState.isValidating && (
|
||
<span className="input-group-text">
|
||
{validationState.valid === true && (
|
||
<IconCheck size={18} className="text-success" />
|
||
)}
|
||
{validationState.valid === false && (
|
||
<IconX size={18} className="text-danger" />
|
||
)}
|
||
</span>
|
||
)}
|
||
|
||
{/* Индикатор процесса валидации */}
|
||
{validationState.isValidating && (
|
||
<span className="input-group-text">
|
||
<span className="spinner-border spinner-border-sm" role="status"></span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Подсказка */}
|
||
{hint && !validationState.message && (
|
||
<small id={`${props.id}-hint`} className="form-hint text-muted">
|
||
{hint}
|
||
</small>
|
||
)}
|
||
|
||
{/* Сообщение об ошибке */}
|
||
{isTouched && validationState.message && (
|
||
<div
|
||
id={`${props.id}-feedback`}
|
||
className={`invalid-feedback d-block ${validationState.valid ? 'text-success' : 'text-danger'}`}
|
||
>
|
||
<IconAlertCircle size={14} className="me-1" />
|
||
{validationState.message}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default ValidatedInput
|
||
|