Skip to content

Instantly share code, notes, and snippets.

@turizoft
Created September 6, 2023 16:42
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save turizoft/e17f7e822a66188f790efcb3576001ec to your computer and use it in GitHub Desktop.
Save turizoft/e17f7e822a66188f790efcb3576001ec to your computer and use it in GitHub Desktop.
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