nexxtway/react-rainbow

View on GitHub
src/components/PercentInput/index.js

Summary

Maintainability
F
6 days
Test Coverage
/* eslint-disable react/no-unused-prop-types */
import React, { forwardRef, useState, useEffect, useRef, useImperativeHandle } from 'react';
import PropTypes from 'prop-types';
import {
    useLocale,
    useUniqueIdentifier,
    useReduxForm,
    useErrorMessageId,
    useLabelId,
} from '../../libs/hooks';
import { clearValue, countCharacters, isValidStringNumber } from '../CurrencyInput/helpers';
import { useDecimalSeparator, useGroupSeparator } from '../CurrencyInput/hooks';
import useOptions from './hooks/useOptions';
import Label from '../Input/label';
import RenderIf from '../RenderIf';
import RelativeElement from '../Structural/relativeElement';
import StyledContainer from '../Input/styled/container';
import StyledIconContainer from '../Input/styled/iconContainer';
import { StyledInput } from '../Input/inputBase/styled';
import HelpText from '../Input/styled/helpText';
import ErrorText from '../Input/styled/errorText';
import formatPercent from './helpers/formatPercent';
import { normalizeValue } from './helpers';

const PercentInput = forwardRef((props, ref) => {
    const {
        value,
        name,
        placeholder,
        icon,
        iconPosition,
        bottomHelpText,
        required,
        error,
        disabled,
        readOnly,
        onChange,
        tabIndex,
        onClick,
        onFocus,
        onBlur,
        onKeyDown,
        className,
        style,
        variant,
        id,
        label,
        labelAlignment,
        hideLabel,
        locale: localeProp,
        size,
        valueAlignment,
        borderRadius,
    } = useReduxForm(props);

    const inputRef = useRef();
    const inputId = useUniqueIdentifier('percent-input');
    const errorMessageId = useErrorMessageId(error);
    const labelId = useLabelId(label);

    const [focused, setFocused] = useState(false);
    const [cursor, setCursor] = useState(0);
    const options = useOptions(props);
    const locale = useLocale(localeProp);
    const decimalSeparator = useDecimalSeparator({ locale, style: 'percent' });
    const groupSeparator = useGroupSeparator({ locale, style: 'percent' });

    const normalizedValue = normalizeValue({ value, locale, decimalSeparator, options });
    const currentValue = focused ? normalizedValue : formatPercent({ value, locale, options });

    const isReadOnly = !!(!disabled && readOnly);

    useImperativeHandle(ref, () => ({
        focus: () => {
            inputRef.current.focus();
        },
        click: () => {
            inputRef.current.click();
        },
        blur: () => {
            inputRef.current.blur();
        },
    }));

    useEffect(() => {
        inputRef.current.setSelectionRange(cursor, cursor);
    }, [cursor]);

    const handleChange = event => {
        const { value: newValue, selectionStart } = event.target;
        const cleanValue = clearValue(newValue, decimalSeparator);
        const normalizeNewValue = normalizeValue({
            value: cleanValue,
            locale,
            decimalSeparator,
            options,
        });

        if (isValidStringNumber(cleanValue) && normalizeNewValue !== normalizedValue) {
            onChange(cleanValue);
            const diffOfChar =
                countCharacters(normalizeNewValue, groupSeparator) -
                countCharacters(normalizedValue, groupSeparator);

            const newCursor = selectionStart + diffOfChar;
            if (
                cleanValue.charAt(0) === '0' &&
                cleanValue.charAt(1) &&
                cleanValue.charAt(1) !== '0' &&
                cleanValue.charAt(1) !== '.'
            ) {
                return setCursor(newCursor - 1);
            }

            if (diffOfChar > -2) {
                return setCursor(newCursor);
            }
            return setCursor(selectionStart);
        }
        const diffOfChar = normalizedValue.length - newValue.length;
        return setCursor(selectionStart + diffOfChar);
    };

    const handleBlur = event => {
        setFocused(false);
        onBlur(event);
    };

    const handleFocus = event => {
        setFocused(true);
        onFocus(event);
    };

    return (
        <StyledContainer id={id} className={className} style={style}>
            <Label
                label={label}
                labelAlignment={labelAlignment}
                hideLabel={hideLabel}
                required={required}
                inputId={inputId}
                readOnly={isReadOnly}
                id={labelId}
                size={size}
            />
            <RelativeElement>
                <RenderIf isTrue={icon}>
                    <StyledIconContainer
                        iconPosition={iconPosition}
                        readOnly={readOnly}
                        error={error}
                        size={size}
                    >
                        {icon}
                    </StyledIconContainer>
                </RenderIf>

                <StyledInput
                    ref={inputRef}
                    id={inputId}
                    name={name}
                    type="text"
                    value={currentValue}
                    placeholder={placeholder}
                    onChange={handleChange}
                    tabIndex={tabIndex}
                    onFocus={handleFocus}
                    onBlur={handleBlur}
                    onClick={onClick}
                    onKeyDown={onKeyDown}
                    disabled={disabled}
                    readOnly={readOnly}
                    required={required}
                    aria-labelledby={labelId}
                    aria-describedby={errorMessageId}
                    iconPosition={iconPosition}
                    icon={icon}
                    error={error}
                    variant={variant}
                    size={size}
                    valueAlignment={valueAlignment}
                    borderRadius={borderRadius}
                    pattern="\d*"
                />
            </RelativeElement>
            <RenderIf isTrue={bottomHelpText}>
                <HelpText alignSelf="center">{bottomHelpText}</HelpText>
            </RenderIf>
            <RenderIf isTrue={error}>
                <ErrorText alignSelf="center" id={errorMessageId}>
                    {error}
                </ErrorText>
            </RenderIf>
        </StyledContainer>
    );
});

PercentInput.propTypes = {
    /** Specifies the value of an input element. */
    value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
    /** The name of the input. */
    name: PropTypes.string,
    /** Text label for the Input. */
    label: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
    /** Describes the position of the Input label. Options include left, center and right.
     * This value defaults to center. */
    labelAlignment: PropTypes.oneOf(['left', 'center', 'right']),
    /** A boolean to hide the Input label. */
    hideLabel: PropTypes.bool,
    /** Text that is displayed when the field is empty, to prompt the user for a valid entry. */
    placeholder: PropTypes.string,
    /** The icon to show if it is passed. It must be a svg icon or a font icon. */
    icon: PropTypes.node,
    /** Describes the position of the icon with respect to body. Options include left and right.
     * This value defaults to left. */
    iconPosition: PropTypes.oneOf(['left', 'right']),
    /** Shows the help message below the Input. */
    bottomHelpText: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
    /** Specifies that an input field must be filled out before submitting the form.
     * This value defaults to false. */
    required: PropTypes.bool,
    /** Specifies that an input field must be filled out before submitting the form. */
    error: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
    /** Specifies that an input element should be disabled. This value defaults to false. */
    disabled: PropTypes.bool,
    /** Specifies that an input field is read-only. This value defaults to false. */
    readOnly: PropTypes.bool,
    /** Specifies the tab order of an element (when the tab button is used for navigating). */
    tabIndex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
    /** The action triggered when a value attribute changes. */
    onChange: PropTypes.func,
    /** The action triggered when the element is clicked. */
    onClick: PropTypes.func,
    /** The action triggered when the element receives focus. */
    onFocus: PropTypes.func,
    /** The action triggered when the element releases focus. */
    onBlur: PropTypes.func,
    /** The action triggered when a key is pressed on the element. */
    onKeyDown: PropTypes.func,
    /** A CSS class for the outer element, in addition to the component's base classes. */
    className: PropTypes.string,
    /** An object with custom style applied to the outer element. */
    style: PropTypes.object,
    /** The variant changes the appearance of the Input. Accepted variants include default,
     *shaded and bare. This value defaults to default. */
    variant: PropTypes.oneOf(['default', 'shaded', 'bare']),
    /** The id of the outer element. */
    id: PropTypes.string,
    /** The CurrencyInput locale. Defaults to browser's language. */
    locale: PropTypes.string,
    /** The minimum number of integer digits to use.
     * A value with a smaller number of integer digits than this number will be left-padded with zeros (to the specified
     *  length) when formatted. Possible values are from 1 to 21; The default is 1. */
    minimumIntegerDigits: PropTypes.number,
    /** The minimum number of fraction digits to use. Possible values are from 0 to 20;
     *  the default for percent formatting is 0; */
    minimumFractionDigits: PropTypes.number,
    /** The maximum number of fraction digits to use. Possible values are from 0 to 20;
     *  the default for percent formatting is the larger of minimumFractionDigits and 0. */
    maximumFractionDigits: PropTypes.number,
    /** The minimum number of significant digits to use. Possible values are from 1 to 21; The default is 1. */
    minimumSignificantDigits: PropTypes.number,
    /** The maximum number of significant digits to use. Possible values are from 1 to 21; The default is 21. */
    maximumSignificantDigits: PropTypes.number,
    /** The size of the input. Valid values are small, medium, and large. */
    size: PropTypes.oneOf(['small', 'medium', 'large']),
    /** Specifies the alignment of the value text. This value defaults to left. */
    valueAlignment: PropTypes.oneOf(['left', 'center', 'right']),
    /** The border radius of the input. Valid values are square, semi-square, semi-rounded and rounded. This value defaults to rounded. */
    borderRadius: PropTypes.oneOf(['square', 'semi-square', 'semi-rounded', 'rounded']),
};

PercentInput.defaultProps = {
    value: undefined,
    name: undefined,
    placeholder: null,
    icon: undefined,
    iconPosition: 'left',
    bottomHelpText: null,
    required: false,
    error: null,
    disabled: false,
    readOnly: false,
    onChange: () => {},
    tabIndex: undefined,
    onClick: () => {},
    onFocus: () => {},
    onBlur: () => {},
    onKeyDown: () => {},
    className: undefined,
    style: undefined,
    variant: 'default',
    id: undefined,
    label: undefined,
    labelAlignment: 'center',
    hideLabel: false,
    locale: undefined,
    minimumIntegerDigits: undefined,
    minimumFractionDigits: undefined,
    maximumFractionDigits: undefined,
    minimumSignificantDigits: undefined,
    maximumSignificantDigits: undefined,
    size: 'medium',
    valueAlignment: 'left',
    borderRadius: 'rounded',
};

export default PercentInput;