nexxtway/react-rainbow

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

Summary

Maintainability
C
1 day
Test Coverage
/* eslint-disable react/sort-comp */
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import withReduxForm from '../../libs/hocs/withReduxForm';
import RenderIf from '../RenderIf';
import { ESCAPE_KEY, TAB_KEY } from '../../libs/constants';
import { uniqueId } from '../../libs/utils';
import OutsideClick from '../../libs/outsideClick';
import { WindowScrolling } from '../../libs/scrollController';
import Label from '../Input/label';
import getNormalizeValue from './helpers/getNormalizeValue';
import shouldOpenMenu from './helpers/shouldOpenMenu';
import StyledInput from './styled/input';
import StyledContainer from './styled/container';
import StyledInnerContainer from './styled/innerContainer';
import StyledIcon from './styled/icon';
import StyledError from '../Input/styled/errorText';
import StyledIndicator from './styled/indicator';
import InternalDropdown from '../InternalDropdown';
import InternalOverlay from '../InternalOverlay';
import WindowResize from '../../libs/WindowResize';

function positionResolver(opts, enableSearch) {
    const { trigger, viewport, content } = opts;
    const newOpts = {
        trigger,
        viewport,
        content: {
            ...content,
            width: trigger.width,
        },
    };
    if (enableSearch && viewport.width <= 600) {
        return {
            top: 0,
            left: 0,
            width: viewport.width,
        };
    }
    return {
        ...InternalOverlay.defaultPositionResolver(newOpts),
        width: trigger.width,
    };
}

/**
 * A Picklist provides a user with an read-only input field that is accompanied with
 *  a listbox of pre-defined options.
 * @category Form
 */
class Picklist extends Component {
    constructor(props) {
        super(props);
        this.inputId = uniqueId('picklist-input');
        this.errorMessageId = uniqueId('error-message');
        this.listboxId = uniqueId('listbox');
        this.containerRef = React.createRef();
        this.triggerRef = React.createRef();
        this.dropdownRef = React.createRef();
        this.handleInputClick = this.handleInputClick.bind(this);
        this.handleFocus = this.handleFocus.bind(this);
        this.handleBlur = this.handleBlur.bind(this);
        this.handleKeyPressed = this.handleKeyPressed.bind(this);
        this.handleChange = this.handleChange.bind(this);
        this.closeAndFocusInput = this.closeAndFocusInput.bind(this);
        this.handleWindowScroll = this.handleWindowScroll.bind(this);
        this.handleWindowResize = this.handleWindowResize.bind(this);
        this.outsideClick = new OutsideClick();
        this.windowScrolling = new WindowScrolling();
        this.windowResize = new WindowResize();
        this.activeChildren = [];
        this.state = {
            isOpen: false,
        };
        this.keyHandlerMap = {
            [ESCAPE_KEY]: this.closeAndFocusInput,
            [TAB_KEY]: this.closeAndFocusInput,
        };
    }

    componentDidUpdate(prevProps, prevState) {
        const { isOpen: wasOpen } = prevState;
        const { isOpen } = this.state;
        if (!wasOpen && isOpen) {
            // eslint-disable-next-line id-length
            this.outsideClick.startListening(this.containerRef.current, (_, event) => {
                if (!this.dropdownRef.current.contains(event.target)) {
                    this.closeMenu();
                    this.handleBlur();
                }
            });
            if (window.screen.width > 600) {
                this.windowScrolling.startListening(this.handleWindowScroll);
            }
            this.windowResize.startListening(this.handleWindowResize);
        }
    }

    componentWillUnmount() {
        this.outsideClick.stopListening();
        this.windowScrolling.stopListening();
        this.windowResize.stopListening();
    }

    getErrorMessageId() {
        const { error } = this.props;
        if (error) {
            return this.errorMessageId;
        }
        return undefined;
    }

    handleKeyPressed(event) {
        const { isOpen } = this.state;
        const { readOnly } = this.props;
        if (isOpen) {
            if (this.keyHandlerMap[event.keyCode]) {
                return this.keyHandlerMap[event.keyCode]();
            }
        } else if (shouldOpenMenu(event.keyCode) && !readOnly) {
            event.preventDefault();
            this.openMenu();
        }
        return null;
    }

    handleWindowScroll(event) {
        if (this.dropdownRef.current.contains(event.target)) return;
        this.closeMenu();
    }

    handleWindowResize() {
        this.closeMenu();
    }

    closeAndFocusInput() {
        this.closeMenu();
        this.focus();
    }

    openMenu() {
        const { readOnly } = this.props;
        if (!readOnly) {
            this.setState({
                isOpen: true,
            });
        }
    }

    closeMenu() {
        this.outsideClick.stopListening();
        this.windowScrolling.stopListening();
        this.windowResize.stopListening();
        this.setState({
            isOpen: false,
        });
    }

    handleInputClick(event) {
        const { onClick } = this.props;
        const { isOpen } = this.state;
        onClick(event);
        if (isOpen) {
            return this.closeMenu();
        }
        return this.openMenu();
    }

    handleFocus() {
        const { onFocus, value } = this.props;
        const eventValue = value || null;
        onFocus(eventValue);
    }

    handleBlur() {
        const { isOpen } = this.state;
        if (isOpen) return;
        const { onBlur, value } = this.props;
        const eventValue = value || null;
        onBlur(eventValue);
    }

    handleChange(option) {
        const { onChange } = this.props;
        const { label, name, icon, value } = option;
        this.closeMenu();
        setTimeout(() => {
            this.focus();
            return onChange({ label, name, icon, value });
        }, 0);
    }

    /**
     * Sets focus on the element.
     * @public
     */
    focus() {
        this.triggerRef.current.focus();
    }

    /**
     * Sets click on the element.
     * @public
     */
    click() {
        this.triggerRef.current.click();
    }

    /**
     * Sets blur on the element.
     * @public
     */
    blur() {
        this.triggerRef.current.blur();
    }

    render() {
        const {
            label: pickListLabel,
            labelAlignment,
            hideLabel,
            style,
            className,
            variant,
            error,
            isLoading,
            disabled,
            readOnly,
            required,
            children,
            id,
            tabIndex,
            placeholder,
            name,
            value: valueInProps,
            enableSearch,
            onSearch,
            debounce,
            emptyComponent,
            size,
            borderRadius,
        } = this.props;
        const { label: valueLabel, icon } = getNormalizeValue(valueInProps);
        const value = valueLabel || '';
        const errorMessageId = this.getErrorMessageId();
        const { isOpen } = this.state;
        const isReadOnly = !!(!disabled && readOnly);
        const labelVariant = variant === 'inverse' ? variant : 'default';
        return (
            <StyledContainer
                id={id}
                role="presentation"
                className={className}
                style={style}
                onKeyDown={this.handleKeyPressed}
                ref={this.containerRef}
                readOnly={readOnly}
            >
                <RenderIf isTrue={pickListLabel}>
                    <Label
                        label={pickListLabel}
                        labelAlignment={labelAlignment}
                        hideLabel={hideLabel}
                        required={required}
                        inputId={this.inputId}
                        readOnly={isReadOnly}
                        variant={labelVariant}
                        size={size}
                    />
                </RenderIf>

                <StyledInnerContainer
                    disabled={disabled}
                    readOnly={readOnly}
                    aria-expanded={isOpen}
                    aria-haspopup="listbox"
                    // eslint-disable-next-line jsx-a11y/role-has-required-aria-props
                    role="combobox"
                >
                    <RenderIf isTrue={icon}>
                        <StyledIcon error={error}>{icon}</StyledIcon>
                    </RenderIf>
                    <RenderIf isTrue={!readOnly}>
                        <StyledIndicator error={error} disabled={disabled} />
                    </RenderIf>
                    <StyledInput
                        aria-controls={this.listboxId}
                        id={this.inputId}
                        type="text"
                        name={name}
                        value={value}
                        error={error}
                        onClick={this.handleInputClick}
                        onFocus={this.handleFocus}
                        onBlur={this.handleBlur}
                        placeholder={placeholder}
                        tabIndex={tabIndex}
                        readOnly
                        isReadOnly={readOnly}
                        disabled={disabled}
                        required={required}
                        aria-describedby={errorMessageId}
                        autoComplete="off"
                        ref={this.triggerRef}
                        icon={icon}
                        iconPosition="left"
                        variant={variant}
                        size={size}
                        borderRadius={borderRadius}
                    />
                    <InternalOverlay
                        isVisible={isOpen}
                        positionResolver={opt => positionResolver(opt, enableSearch)}
                        onOpened={() => this.dropdownRef.current.focus()}
                        triggerElementRef={() => this.triggerRef}
                        keepScrollEnabled
                    >
                        <InternalDropdown
                            id={this.listboxId}
                            isLoading={isLoading}
                            value={valueInProps}
                            onChange={this.handleChange}
                            enableSearch={enableSearch}
                            onSearch={onSearch}
                            debounce={debounce}
                            ref={this.dropdownRef}
                            emptyComponent={emptyComponent}
                            borderRadius={borderRadius}
                        >
                            {children}
                        </InternalDropdown>
                    </InternalOverlay>
                </StyledInnerContainer>
                <RenderIf isTrue={error}>
                    <StyledError id={errorMessageId}>{error}</StyledError>
                </RenderIf>
            </StyledContainer>
        );
    }
}

Picklist.propTypes = {
    /** Text label for the PickList. */
    label: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
    /** Describes the position of the PickList label. Options include left, center and right.
     * This value defaults to center. */
    labelAlignment: PropTypes.oneOf(['left', 'center', 'right']),
    /** A boolean to hide the PickList label. */
    hideLabel: PropTypes.bool,
    /** The content of the Picklist. Used to render the options
     * when the Picklist is open. */
    children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.object]),
    /** If is set to true, then is showed a loading symbol. */
    isLoading: PropTypes.bool,
    /** Specifies the selected value of the Picklist. */
    value: PropTypes.oneOfType([
        PropTypes.shape({
            label: PropTypes.string,
            name: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
            icon: PropTypes.node,
            value: PropTypes.any,
        }),
        PropTypes.string,
    ]),
    /**  The action triggered when click/select an option. */
    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,
    /** Specifies the tab order of an element (when the tab button is used for navigating). */
    tabIndex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
    /** Text that is displayed when the field is empty, to prompt the user for a valid entry. */
    placeholder: PropTypes.string,
    /** The name of the Picklist. */
    name: PropTypes.string,
    /** Specifies that the Picklist must be filled out before submitting the form. */
    error: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
    /** Specifies that an option must be selected before submitting the form.
     * This value defaults to false. */
    required: PropTypes.bool,
    /** Specifies that the Picklist element should be disabled. This value defaults to false. */
    disabled: PropTypes.bool,
    /** Specifies that the Picklist is read-only. This value defaults to false. */
    readOnly: PropTypes.bool,
    /** The id of the outer element. */
    id: PropTypes.string,
    /** 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 Picklist. Accepted variants include default,
     * shaded, bare and inverse. This value defaults to default. */
    variant: PropTypes.oneOf(['default', 'shaded', 'bare', 'inverse']),
    /** If is set to true, then a search input to filter is showed. */
    enableSearch: PropTypes.bool,
    /** Action triggered when search query changes */
    onSearch: PropTypes.func,
    /** When true, the onSearch callback will be debounced */
    debounce: PropTypes.bool,
    /** A component that is displayed when no search matches are found */
    emptyComponent: PropTypes.node,
    /** The size of the input. Valid values are small, medium, and large. */
    size: PropTypes.oneOf(['small', 'medium', 'large']),
    /** 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']),
};

Picklist.defaultProps = {
    label: undefined,
    children: null,
    isLoading: false,
    value: undefined,
    onChange: () => {},
    onClick: () => {},
    onFocus: () => {},
    onBlur: () => {},
    tabIndex: undefined,
    placeholder: undefined,
    name: undefined,
    labelAlignment: 'center',
    hideLabel: false,
    id: undefined,
    error: null,
    disabled: false,
    readOnly: false,
    required: false,
    className: undefined,
    style: undefined,
    variant: 'default',
    enableSearch: false,
    onSearch: undefined,
    debounce: false,
    emptyComponent: undefined,
    size: 'medium',
    borderRadius: 'rounded',
};

export default withReduxForm(Picklist);
export { Picklist as Component };