Created
September 6, 2023 16:42
-
-
Save turizoft/e17f7e822a66188f790efcb3576001ec to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import clsx from 'clsx'; | |
import React, { | |
SyntheticEvent, useCallback, useEffect, useMemo, | |
} from 'react'; | |
import { | |
FieldValues, RegisterOptions, UseFormReturn, | |
} from 'react-hook-form'; | |
import Skeleton from 'react-loading-skeleton'; | |
import Icon, { IconName } from '@/components/icons/Icon'; | |
import Button, { ButtonSize, ButtonVariant } from '../button/Button'; | |
import Alert from '../alert/Alert'; | |
type ValueType = string | number; | |
/** | |
* Props for the Input component. | |
* @interface IInputProps | |
*/ | |
interface IInputProps extends React.InputHTMLAttributes<HTMLInputElement> { | |
/** | |
* The useForm object from react-hook-form. | |
*/ | |
useFormHandle?: UseFormReturn<FieldValues>; | |
/** | |
* The name of the input. | |
*/ | |
name: string; | |
/** | |
* The value of the select | |
*/ | |
value?: ValueType; | |
/** | |
* Handle value manipulation without react-hook-form | |
*/ | |
onChange?: (value?: ValueType) => void; | |
/** | |
* The label of the input. | |
*/ | |
label?: string; | |
/** | |
* Additional class name(s) for the input wrapper. | |
*/ | |
className?: string; | |
/** | |
* Additional class name(s) for the input. | |
*/ | |
inputClassName?: string; | |
/** | |
* Additional class name(s) for the label. | |
*/ | |
labelClassName?: string; | |
/** | |
* Whether the input is required. | |
*/ | |
isRequired?: boolean; | |
/** | |
* Whether the input is disabled. | |
*/ | |
isDisabled?: boolean; | |
/** | |
* Whether the input is displayed inline with the label. | |
*/ | |
isInline?: boolean; | |
/** | |
* The maximum number of characters allowed in the input. | |
*/ | |
maxCharacters?: number; | |
/** | |
* Whether the input renders a textarea. | |
*/ | |
isMultiline?: boolean; | |
/** | |
* Whether the input is a price. | |
*/ | |
isPrice?: boolean; | |
/** | |
* Whether the textarea is resizable. | |
*/ | |
isResizable?: boolean; | |
/** | |
* Whether the input is borderless. | |
*/ | |
isBorderless?: boolean; | |
/** | |
* Whether the label has a small font size. | |
*/ | |
hasSmallLabel?: boolean; | |
/** | |
* Whether the component should be rendered as a skeleton loading animation. | |
*/ | |
isSkeleton?: boolean; | |
/** | |
* The number of rows to display in the textarea. | |
*/ | |
rows?: number; | |
/** | |
* The class name(s) for the attached buttons. | |
*/ | |
attachedButtonsClassName?: string; | |
} | |
/** | |
* Input component to display a form input. | |
* @param {IInputProps} props - The props for the Input component. | |
* @param {UseFormReturn<FieldValues>} props.useFormHandle - The useForm object from react-hook-form. | |
* @param {string} props.name - The name of the input. | |
* @param {string} props.value - The value of the select | |
* @param {(value?: ValueType) => void} props.onChange - Handle value manipulation without react-hook-form | |
* @param {string} props.label - The label of the input. | |
* @param {string} props.className - Additional class name(s) for the input wrapper. | |
* @param {string} props.inputClassName - Additional class name(s) for the input. | |
* @param {string} props.labelClassName - Additional class name(s) for the label. | |
* @param {boolean} props.isRequired - Whether the input is required. | |
* @param {boolean} props.isDisabled - Whether the input is disabled. | |
* @param {boolean} props.isInline - Whether the input is displayed inline with the label. | |
* @param {number} props.maxCharacters - The maximum number of characters allowed in the input. | |
* @param {boolean} props.isMultiline - Whether the input renders a textarea. | |
* @param {boolean} props.isPrice - Whether the input is a price. | |
* @param {boolean} props.isResizable - Whether the textarea is resizable. | |
* @param {boolean} props.isBorderless - Whether the input is borderless. | |
* @param {boolean} props.hasSmallLabel - Whether the label has a small font size. | |
* @param {boolean} props.isSkeleton - Whether the component should be rendered as a skeleton loading animation. | |
* @param {number} props.rows - The number of rows to display in the textarea. | |
* @param {string} props.attachedButtonsClassName - The class name(s) for the attached buttons. | |
* @returns {React.JSX.Element} - The rendered Input component. | |
*/ | |
function Input({ | |
useFormHandle, | |
name, | |
value: customValue, | |
onChange, | |
label, | |
className, | |
inputClassName, | |
labelClassName, | |
isRequired, | |
maxCharacters, | |
isDisabled, | |
isInline, | |
isMultiline, | |
isPrice, | |
isResizable, | |
isBorderless, | |
hasSmallLabel, | |
isSkeleton, | |
rows, | |
attachedButtonsClassName, | |
...rest | |
}: IInputProps): JSX.Element { | |
const id = `${name}-input`; | |
const errorId = `${name}-error`; | |
const isNumeric = rest.type === 'number'; | |
const { | |
register, watch, setValue: formHandleSetValue, formState, | |
} = useFormHandle || {}; | |
const error = formState?.errors?.[name]; | |
const value = (watch?.(name) as ValueType) || customValue; | |
const valueIfNumeric = !Number.isNaN(Number(value)) ? Number(value) : undefined; | |
const numericValue = isNumeric && value != null ? valueIfNumeric : 0; | |
const remainingCharacters = (maxCharacters || 0) - (value?.length || 0); | |
const countCharacters = maxCharacters ? remainingCharacters / maxCharacters < 0.5 : false; | |
const noRemainingCharacters = maxCharacters ? remainingCharacters <= 0 : false; | |
const { min, max } = rest; | |
const minValue = min !== undefined ? Number(min) : undefined; | |
const maxValue = max !== undefined ? Number(max) : undefined; | |
const canDecreaseValue = numericValue !== undefined && (minValue === undefined || numericValue > minValue); | |
const canIncreaseValue = numericValue !== undefined && (maxValue === undefined || numericValue < maxValue); | |
const setValue = useCallback((newValue: ValueType) => { | |
if (formHandleSetValue) { | |
formHandleSetValue(name, newValue?.toString()); | |
} | |
onChange?.(newValue); | |
}, [onChange, formHandleSetValue, name]); | |
const handleDecreaseValue = useCallback(() => { | |
if (numericValue !== undefined) { | |
setValue?.(numericValue - 1); | |
} | |
}, [numericValue]); | |
const handleIncreaseValue = useCallback(() => { | |
if (numericValue !== undefined) { | |
setValue?.(numericValue + 1); | |
} | |
}, [numericValue]); | |
const handleOnChange = useCallback((e: SyntheticEvent) => { | |
const inputValue = (e.target as HTMLInputElement).value; | |
setValue?.(inputValue === '' || inputValue == null ? null : inputValue); | |
}, []); | |
const handleOnChangeNumeric = useCallback((e: SyntheticEvent) => { | |
const inputValue = (e.target as HTMLInputElement).value; | |
const parsedValue = inputValue.replace(/[^0-9.]/g, ''); | |
if (parsedValue === '' || parsedValue == null) { | |
setValue?.(isRequired ? 0 : null); | |
return; | |
} | |
let numericInputValue: string | number = Number(parsedValue); | |
numericInputValue = minValue !== undefined ? Math.max(minValue, numericInputValue) : numericInputValue; | |
numericInputValue = maxValue !== undefined ? Math.min(maxValue, numericInputValue) : numericInputValue; | |
numericInputValue = isPrice ? `$${numericInputValue.toLocaleString()}` : numericInputValue; | |
setValue?.(numericInputValue); | |
}, [minValue, maxValue, isPrice, isRequired]); | |
const handleSetValueAs = useCallback((inputValue: unknown) => { | |
if (inputValue === '' || inputValue == null) { | |
return null; | |
} | |
const parsedValue = isPrice ? String(inputValue).replace(/[^0-9.]/g, '') : inputValue; | |
if (parsedValue === '') { | |
return null; | |
} | |
return Number.isNaN(Number(parsedValue)) ? inputValue : Number(parsedValue); | |
}, [isPrice]); | |
const registerOptions = useMemo<RegisterOptions>( | |
() => ({ | |
min: isNumeric ? minValue : undefined, | |
max: isNumeric ? maxValue : undefined, | |
setValueAs: isNumeric || isPrice ? handleSetValueAs : undefined, | |
onChange: isNumeric || isPrice ? handleOnChangeNumeric : handleOnChange, | |
}), | |
[isNumeric, isPrice, minValue, maxValue, handleOnChange, handleOnChangeNumeric, handleSetValueAs] | |
); | |
const rootClass = useMemo( | |
() => clsx( | |
'flex w-full appearance-none items-center rounded-md bg-light-1000 px-3 py-2 text-gray-700 placeholder:text-gray-500', | |
'focus:z-20 focus:shadow-lg focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-1', | |
isMultiline ? 'h-27' : 'h-11', | |
{ | |
'resize-none': !isResizable, | |
'leading-snug scrollbar-sm': isMultiline, | |
'rounded-l-none rounded-r-none -mx-2px': isNumeric, | |
'border-2 border-gray-400 shadow-sm': !isBorderless, | |
}, | |
inputClassName | |
), | |
[isResizable, isMultiline, isNumeric, isBorderless, inputClassName] | |
); | |
const labelClass = useMemo( | |
() => clsx( | |
'relative flex cursor-pointer items-center font-medium leading-tight text-gray-600', | |
isInline ? 'mr-3' : 'mb-2', | |
hasSmallLabel ? 'text-sm' : 'text-base', | |
{ | |
'pointer-events-none cursor-default select-none opacity-50': isDisabled, | |
'after:text-sm after:text-gray-500 after:content-["*"]': isRequired && !isSkeleton, | |
}, | |
labelClassName | |
), | |
[isInline, labelClassName] | |
); | |
const attachedButtonsClass = useMemo( | |
() => clsx( | |
'z-10 self-stretch border-2 border-gray-400 px-1 shadow-sm', | |
attachedButtonsClassName | |
), | |
[attachedButtonsClassName] | |
); | |
const skeletonClass = useMemo( | |
() => clsx( | |
'pointer-events-none relative select-none overflow-hidden', | |
className | |
), | |
[className] | |
); | |
const skeletonLabelClass = useMemo( | |
() => clsx( | |
'w-32', | |
labelClass | |
), | |
[labelClass] | |
); | |
const skeletonInputClass = useMemo( | |
() => clsx( | |
'block', | |
isMultiline ? 'h-27' : 'h-11' | |
), | |
[isMultiline] | |
); | |
useEffect(() => { | |
if (isPrice && value != null) { | |
handleOnChangeNumeric({ target: { value: value.toString() } }); | |
} | |
}, []); | |
if (isSkeleton) { | |
return ( | |
<div className={skeletonClass}> | |
{label && <Skeleton className={skeletonLabelClass} inline />} | |
<Skeleton className={skeletonInputClass} containerClassName="block" inline /> | |
</div> | |
); | |
} | |
return ( | |
<div className={className}> | |
{label && ( | |
<label | |
htmlFor={id} | |
className={labelClass} | |
> | |
{label} | |
{countCharacters && ( | |
<div className="absolute right-0 flex items-center"> | |
<span | |
className={clsx('ml-2 text-xs text-gray-600', { 'text-red-500': noRemainingCharacters })} | |
> | |
{remainingCharacters} | |
</span> | |
</div> | |
)} | |
</label> | |
)} | |
{isMultiline ? ( | |
<textarea | |
id={id} | |
className={rootClass} | |
disabled={isDisabled} | |
aria-describedby={error ? errorId : undefined} | |
aria-invalid={!!error} | |
rows={rows || 3} | |
{...rest} | |
{...register?.(name, registerOptions)} | |
/> | |
) : ( | |
<div className="relative flex items-center"> | |
{isNumeric && ( | |
<Button | |
variant={ButtonVariant.White} | |
size={ButtonSize.Narrow} | |
className={attachedButtonsClass} | |
roundedClass="rounded-l" | |
isDisabled={isDisabled || !canDecreaseValue} | |
onClick={handleDecreaseValue} | |
> | |
<Icon name={IconName.BxMinus} size={14} /> | |
</Button> | |
)} | |
<input | |
id={id} | |
className={rootClass} | |
disabled={isDisabled} | |
aria-describedby={error ? errorId : undefined} | |
aria-invalid={!!error} | |
{...rest} | |
{...register?.(name, registerOptions)} | |
/> | |
{isNumeric && ( | |
<Button | |
variant={ButtonVariant.White} | |
size={ButtonSize.Narrow} | |
className={attachedButtonsClass} | |
roundedClass="rounded-r" | |
isDisabled={isDisabled || !canIncreaseValue} | |
onClick={handleIncreaseValue} | |
> | |
<Icon name={IconName.BxPlus} size={14} /> | |
</Button> | |
)} | |
</div> | |
)} | |
{error?.message && ( | |
<div id={errorId}> | |
<Alert message={error.message.toString()} className="mt-2" /> | |
</div> | |
)} | |
</div> | |
); | |
} | |
export default Input; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment