ahbeng/NUSMods

View on GitHub
website/src/views/components/SearchBox.tsx

Summary

Maintainability
A
0 mins
Test Coverage
import * as React from 'react';
import classnames from 'classnames';
import { debounce } from 'lodash';

import { Search, X } from 'react-feather';
import LoadingSpinner from 'views/components/LoadingSpinner';
import styles from './SearchBox.scss';

type Props = {
  className?: string;
  throttle: number;
  useInstantSearch: boolean;
  isLoading: boolean;
  value: string | null;
  placeholder?: string;
  onChange: (value: string) => void;
  onSearch: () => void;
  onBlur?: () => void;
};

type State = {
  isFocused: boolean;
  hasChanges: boolean;
};

export default class SearchBox extends React.PureComponent<Props, State> {
  searchElement = React.createRef<HTMLInputElement>();

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

    this.state = {
      isFocused: false,
      hasChanges: false,
    };
  }

  onSubmit = () => {
    const element = this.searchElement.current;
    if (!element) return;
    // element's onBlur callback will trigger the search flow. If we also
    // invoke onSearch in onSubmit, onSearch will be called twice.
    element.blur();
  };

  onBlur = () => {
    if (this.props.onBlur) this.props.onBlur();

    const element = this.searchElement.current;
    if (!element) return;

    // Don't search if no changes
    if (!this.state.hasChanges) return;

    const searchTerm = element.value;
    this.props.onChange(searchTerm);
    this.debouncedSearch();
    this.debouncedSearch.flush();
  };

  onInput = (evt: React.ChangeEvent<HTMLInputElement>) => {
    if (evt.target instanceof HTMLInputElement) {
      const searchTerm = evt.target.value;
      this.props.onChange(searchTerm);
      this.setState({ hasChanges: true });
      if (this.props.useInstantSearch) this.debouncedSearch();
    }
  };

  onRemoveInput = () => {
    this.props.onChange('');
    this.setState({ hasChanges: true });
    if (this.props.useInstantSearch) this.debouncedSearch();
  };

  private search = () => {
    this.setState({ hasChanges: false });
    this.props.onSearch();
  };

  // eslint-disable-next-line react/sort-comp
  private debouncedSearch = debounce(this.search, this.props.throttle, {
    leading: false,
  });

  showSubmitHelp() {
    return (
      !this.props.useInstantSearch &&
      this.state.hasChanges &&
      this.searchElement.current &&
      this.searchElement.current.value
    );
  }

  override render() {
    const { value, placeholder, isLoading } = this.props;
    return (
      <div
        className={classnames(this.props.className, {
          [styles.searchBoxFocused]: this.state.isFocused,
        })}
      >
        <label htmlFor="search-box" className="sr-only">
          Search
        </label>
        <form
          className={styles.searchWrapper}
          onSubmit={(evt) => {
            this.onSubmit();
            evt.preventDefault();
          }}
        >
          {isLoading ? (
            <div className={classnames(styles.leftAccessory, styles.spinnerContainer)}>
              <LoadingSpinner className={styles.spinner} small />
            </div>
          ) : (
            <Search className={classnames(styles.leftAccessory, styles.searchIcon)} />
          )}
          {value && (
            <X
              className={styles.removeInput}
              onClick={this.onRemoveInput}
              pointerEvents="bounding-box"
            />
          )}
          <input
            id="search-box"
            className="form-control form-control-lg"
            type="search"
            autoComplete="off"
            ref={this.searchElement}
            value={value || ''}
            onChange={this.onInput}
            onFocus={() => this.setState({ isFocused: true })}
            onBlur={() => {
              this.setState({ isFocused: false });
              this.onBlur();
            }}
            placeholder={placeholder}
            spellCheck
          />
        </form>

        {this.showSubmitHelp() && <p className={styles.searchHelp}>Press enter to search</p>}
      </div>
    );
  }
}