gilbarbara/react-dropdown

View on GitHub
src/index.tsx

Summary

Maintainability
D
2 days
Test Coverage
A
95%
import { ChangeEvent, Component, createRef, KeyboardEvent, MouseEvent, RefObject } from 'react';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import isEqual from '@gilbarbara/deep-equal';

import { defaultProps, SLUG, styledOptions } from '~/config';

import { hexToRGBA } from '~/modules/colors';
import {
  canUseDOM,
  debounce,
  getAllOptions,
  getCursor,
  getLabels,
  getOptionData,
  getStyles,
  isNumber,
  matchOptions,
  px,
} from '~/modules/helpers';

import Clear from '~/components/Clear';
import Content from '~/components/Content';
import Handle from '~/components/Handle';
import Loading from '~/components/Loading';
import Menu from '~/components/Menu';
import Separator from '~/components/Separator';

import { Actions, Methods, Option, OptionKeys, Props, State, Styles } from '~/types';

const ReactDropdown = styled(
  'div',
  styledOptions,
)<Styles & Required<Pick<Props, 'direction' | 'disabled'>>>(props => {
  const { bgColor, borderColor, borderRadius, color, direction, disabled, minHeight, width } =
    props;

  return css`
    align-items: start;
    background-color: ${bgColor};
    border-radius: ${borderRadius};
    border: 1px solid ${borderColor};
    box-sizing: border-box;
    cursor: pointer;
    direction: ${direction};
    display: flex;
    flex-direction: row;
    min-height: ${px(minHeight)};
    position: relative;
    width: ${width};
    ${disabled
      ? `
      cursor: not-allowed;
      opacity: 0.6;
      pointer-events: none;
      `
      : `
      pointer-events: all;
      `};

    :hover,
    :focus-within {
      border-color: ${color};
    }

    :focus,
    :focus-within {
      outline: 0;
      box-shadow: 0 0 0 3px ${hexToRGBA(color, 0.2)};
    }

    * {
      box-sizing: border-box;
    }
  `;
});

export class Dropdown extends Component<Props, State> {
  private readonly dropdownRef: RefObject<HTMLDivElement>;
  private readonly methods: Methods;

  static defaultProps = defaultProps;

  constructor(props: Props) {
    super(props);

    this.state = {
      cursor: null,
      dropdownBounds: {},
      search: '',
      searchResults: props.options,
      status: props.open || false,
      values: props.values || [],
    };

    this.methods = {
      addItem: this.addItem,
      areAllSelected: this.areAllSelected,
      clearAll: this.clearAll,
      createItem: this.createItem,
      // eslint-disable-next-line react/destructuring-assignment
      getDropdownBounds: () => this.state.dropdownBounds,
      getDropdownRef: () => this.dropdownRef.current,
      getInputSize: this.getInputSize,
      getLabels: this.getLabels,
      getOptionData: this.getOptionData,
      getStyles: this.getStyles,
      handleKeyDown: this.handleKeyDown,
      isSelected: this.isSelected,
      removeItem: this.removeItem,
      safeString: this.safeString,
      searchResults: this.searchResults,
      selectAll: this.selectAll,
      setSearch: this.setSearch,
      setStatus: this.setStatus,
      toggleAll: this.toggleAll,
    };

    this.dropdownRef = createRef();
  }

  componentDidMount() {
    if (!canUseDOM()) {
      return;
    }

    window.addEventListener('resize', this.handleResize, true);
    window.addEventListener('scroll', this.handleScroll, true);

    if (this.dropdownRef) {
      this.updateDropdownBounds();
    }
  }

  componentDidUpdate(previousProps: Props, previousState: State) {
    const { search, status, values: stateValues } = this.state;
    const {
      closeOnSelect,
      comparatorFn = defaultProps.comparatorFn,
      multi,
      onChange,
      onClose,
      onOpen,
      open,
      options,
      values = [],
    } = this.props;

    if (
      !comparatorFn(previousProps.values || [], values) &&
      comparatorFn(previousProps.values || [], previousState.values)
    ) {
      this.setState({ values });
      this.updateDropdownBounds();
    }

    if (!comparatorFn(previousProps.options, options)) {
      this.setState({ searchResults: this.searchResults() });
    }

    if (!comparatorFn(previousState.values, stateValues)) {
      this.updateDropdownBounds();

      if (onChange) {
        onChange(stateValues);
      }
    }

    if (previousState.search !== search) {
      this.updateDropdownBounds();
    }

    if (!isEqual(previousState.values, stateValues) && closeOnSelect) {
      this.setStatus('close');
    }

    if (previousProps.open !== open && typeof open === 'boolean') {
      this.setStatus(open ? 'open' : 'close');
    }

    if (previousProps.multi !== multi) {
      this.updateDropdownBounds();
    }

    if (previousState.status && !status) {
      document.removeEventListener('click', this.handleClickOutside);

      this.setState({ cursor: null });

      if (onClose) {
        onClose();
      }
    }

    if (!previousState.status && status) {
      document.addEventListener('click', this.handleClickOutside);

      if (onOpen) {
        onOpen();
      }
    }
  }

  componentWillUnmount() {
    if (!canUseDOM()) {
      return;
    }

    window.removeEventListener('resize', this.handleResize, true);
    window.removeEventListener('scroll', this.handleScroll, true);
    document.removeEventListener('click', this.handleClickOutside);
  }

  handleClickOutside = (event: Event) => {
    const { current } = this.dropdownRef;
    const { target } = event;

    if (!current || !target) {
      return;
    }

    if (current === target || !current.contains(target as Node)) {
      this.setStatus('close');
    }
  };

  handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
    const { cursor, search, searchResults, status, values } = this.state;
    const { create } = this.props;

    if (['ArrowDown', 'ArrowUp'].includes(event.key)) {
      event.stopPropagation();
    }

    switch (event.key) {
      case 'ArrowDown': {
        if (!status) {
          this.setStatus('open');
          this.setState({
            cursor: 0,
          });

          return;
        }

        this.setState({
          cursor: getCursor(cursor, 'down', searchResults),
        });

        break;
      }
      case 'ArrowUp': {
        this.setState({
          cursor: getCursor(cursor, 'up', searchResults),
        });

        break;
      }
      case 'Backspace': {
        if (isNumber(cursor) && !search.length) {
          const nextValues = values.filter(value => !isEqual(value, searchResults[cursor]));

          if (nextValues.length !== values.length) {
            this.setState({
              values: nextValues,
            });
          }
        }

        break;
      }
      case 'Enter': {
        if (isNumber(cursor)) {
          const currentItem = searchResults[cursor];

          if (currentItem && !currentItem.disabled) {
            if (create && matchOptions(values, search, false)) {
              return;
            }

            this.addItem(currentItem);
          } else if (search) {
            this.createItem(search);
          }
        } else if (!search) {
          this.setStatus('toggle', event);
        }

        break;
      }
      case 'Escape': {
        this.setStatus('close');
        break;
      }
    }
  };

  // eslint-disable-next-line react/sort-comp
  handleResize = debounce(() => {
    this.updateDropdownBounds();
  }, 150);

  handleScroll = debounce(() => {
    const { status } = this.state;
    const { closeOnScroll } = this.props;

    this.updateDropdownBounds();

    if (closeOnScroll && status) {
      this.setStatus('close');
    }
  }, 150);

  setStatus = (
    action: Actions,
    event?: Event | MouseEvent<HTMLElement> | KeyboardEvent<HTMLDivElement>,
  ) => {
    const { search, status } = this.state;
    const { clearOnClose, closeOnScroll, closeOnSelect, open, options } = this.props;
    const target = event && ((event.target || event.currentTarget) as HTMLElement);
    const isMenuTarget =
      target &&
      target.offsetParent &&
      target.offsetParent.classList.contains('react-dropdown-menu');

    if (!closeOnScroll && !closeOnSelect && event && isMenuTarget) {
      return;
    }

    if (typeof open === 'boolean') {
      this.setState({ status: open });

      return;
    }

    if (action === 'close' && status) {
      this.dropdownRef.current?.blur();

      this.setState({
        status: false,
        search: clearOnClose ? '' : search,
        searchResults: options,
      });

      return;
    }

    if (action === 'open' && !status) {
      this.setState({ status: true });

      return;
    }

    if (action === 'toggle') {
      this.dropdownRef.current?.focus();

      this.setState({ status: !status });
    }
  };

  updateDropdownBounds = () => {
    if (this.dropdownRef.current) {
      this.setState({
        dropdownBounds: this.dropdownRef.current.getBoundingClientRect(),
      });
    }
  };

  getInputSize = () => {
    const { placeholder, secondaryPlaceholder } = this.props;
    const { search, values } = this.state;

    if (search) {
      return search.length;
    }

    if (secondaryPlaceholder && values.length) {
      return secondaryPlaceholder.length;
    }

    return values.length ? 3 : placeholder?.length || 0;
  };

  getLabels = () => {
    const { labels } = this.props;

    return getLabels(labels);
  };

  // eslint-disable-next-line class-methods-use-this
  getOptionData = (input: Option, key: OptionKeys) =>
    key === 'label' ? getOptionData(input, 'label') : getOptionData(input, 'value');

  getStyles = () => {
    const { styles } = this.props;

    return getStyles(styles);
  };

  addItem = (item: Option) => {
    const { values } = this.state;
    const { clearOnSelect, multi } = this.props;

    if (multi) {
      if (matchOptions(values, getOptionData(item, 'value'))) {
        this.removeItem(null, item, false);

        return;
      }

      this.setState({
        values: [...values, item],
      });
    } else {
      this.setState({
        values: [item],
      });

      this.setStatus('close');
    }

    if (clearOnSelect) {
      this.setState({ search: '' });
    }
  };

  removeItem = (event: MouseEvent<HTMLElement> | null, item: Option, close = false) => {
    const { values } = this.state;

    if (event) {
      event.preventDefault();
      event.stopPropagation();
    }

    if (close) {
      this.setStatus('close');
    }

    this.setState({
      values: values.filter(data => getOptionData(data, 'value') !== getOptionData(item, 'value')),
    });
  };

  setSearch = (event: ChangeEvent<HTMLInputElement>) => {
    this.setState(
      {
        cursor: null,
        search: (event.target as HTMLInputElement).value,
      },
      () => {
        this.setState({ searchResults: this.searchResults() });
      },
    );
  };

  areAllSelected = () => {
    const { options } = this.props;
    const { values } = this.state;

    return values.length === getAllOptions(options, values).length;
  };

  clearAll = () => {
    const { onClearAll } = this.props;

    if (onClearAll) {
      onClearAll();
    }

    this.setState({
      values: [],
    });
  };

  createItem = (search: string) => {
    const { onCreate, options } = this.props;
    const newValue = {
      label: search,
      value: search,
    };

    this.addItem(newValue);

    if (onCreate) {
      onCreate(search, () => this.setStatus('close'));
    }

    this.setState({ search: '', searchResults: [...options, newValue] });
  };

  isSelected = (option: Option) => {
    const { values } = this.state;

    return values.some(value => getOptionData(value, 'value') === getOptionData(option, 'value'));
  };

  selectAll = (valuesList: Option[] = []) => {
    const { values } = this.state;
    const { onSelectAll, options } = this.props;

    if (onSelectAll) {
      onSelectAll();
    }

    const nextValues = valuesList.length ? valuesList : getAllOptions(options, values);

    this.setState({ values: nextValues });
  };

  toggleAll = () => {
    const { values } = this.state;

    if (values.length) {
      this.clearAll();
    } else {
      this.selectAll();
    }
  };

  // eslint-disable-next-line class-methods-use-this
  safeString = (input: string) => input.replace(/[$()*+.?[\\\]^{|}]/g, '\\$&');

  searchFn = () => {
    const { search } = this.state;
    const { options, searchBy = 'label' } = this.props;
    const regexp = new RegExp(this.safeString(search), 'i');

    return options.filter(item => regexp.test(`${this.getOptionData(item, searchBy)}`));
  };

  searchResults = () => {
    const { searchFn } = this.props;

    if (searchFn) {
      return searchFn({ methods: this.methods, props: this.props, state: this.state });
    }

    return this.searchFn();
  };

  render() {
    const { status, values } = this.state;
    const {
      className,
      direction = 'ltr',
      disabled = false,
      hiddenInput,
      hideHandle,
      loading,
      showClearButton,
      showSeparator,
      style,
    } = this.props;

    const classes = [SLUG, className].filter(Boolean).join(' ');

    return (
      <ReactDropdown
        ref={this.dropdownRef}
        aria-expanded={status}
        aria-label="Dropdown"
        className={classes}
        data-component-name="Dropdown"
        direction={direction}
        disabled={disabled}
        onKeyDown={this.handleKeyDown}
        style={style}
        tabIndex={disabled ? -1 : 0}
        {...this.getStyles()}
      >
        <Content methods={this.methods} props={this.props} state={this.state} />

        {hiddenInput && (
          <input
            defaultValue={
              values
                .map(value => {
                  const { key = 'value' } = hiddenInput;

                  if (key === 'value') {
                    return getOptionData(value, 'value');
                  }

                  return getOptionData(value, 'label');
                })
                .join(hiddenInput.separator) || ''
            }
            disabled={disabled}
            name={hiddenInput.name}
            pattern={hiddenInput.pattern}
            required={!!hiddenInput.required}
            style={{ opacity: 0, width: 0, position: 'absolute' }}
            tabIndex={-1}
          />
        )}

        {showClearButton && !!values.length && (
          <Clear methods={this.methods} props={this.props} state={this.state} />
        )}

        {loading && <Loading methods={this.methods} props={this.props} state={this.state} />}

        {showSeparator && (
          <Separator methods={this.methods} props={this.props} state={this.state} />
        )}

        {!hideHandle && <Handle methods={this.methods} props={this.props} state={this.state} />}

        {status && !disabled && (
          <Menu methods={this.methods} props={this.props} state={this.state} />
        )}
      </ReactDropdown>
    );
  }
}

export default Dropdown;

export * from '~/types';