tutorbookapp/tutorbook

View on GitHub
components/select/index.tsx

Summary

Maintainability
C
7 hrs
Test Coverage
import {
  FormEvent,
  MouseEvent,
  SyntheticEvent,
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
import { MenuSurface, MenuSurfaceAnchor } from '@rmwc/menu';
import { TextField, TextFieldHTMLProps, TextFieldProps } from '@rmwc/textfield';
import { Chip } from '@rmwc/chip';
import { MDCMenuSurfaceFoundation } from '@material/menu-surface';
import { nanoid } from 'nanoid';
import to from 'await-to-js';

import CloseIcon from 'components/icons/close';

import { Option } from 'lib/model/query/base';
import { TCallback } from 'lib/model/callback';
import { useClickContext } from 'lib/hooks/click-outside';
import usePrevious from 'lib/hooks/previous';

import SelectHint from './select-hint';
import SelectSurface from './select-surface';
import styles from './select.module.scss';

type TextFieldPropOverrides = 'textarea' | 'onFocus' | 'onBlur';

interface UniqueSelectProps<T, O extends Option<T> = Option<T>> {
  value: O[];
  onChange: TCallback<O[]>;
  getSuggestions: (query: string) => Promise<O[]>;
  forceUpdateSuggestions?: boolean;
  noResultsMessage: string;
  renderToPortal?: boolean;
  autoOpenMenu?: boolean;
  singleLine?: boolean;
  focused?: boolean;
  onFocused?: () => any;
  onBlurred?: () => any;
  create?: string;
  onCreate?: () => void;
}

type Overrides<T, O extends Option<T> = Option<T>> =
  | TextFieldPropOverrides
  | keyof UniqueSelectProps<T, O>;

export type SelectProps<T, O extends Option<T> = Option<T>> = Omit<
  TextFieldHTMLProps,
  Overrides<T, O>
> &
  Omit<TextFieldProps, Overrides<T, O>> &
  UniqueSelectProps<T, O>;

/**
 * Each `Select` component provides a wrapper around the base `Select`
 * component (defined in this file). Those wrappers:
 * 1. Provide a surface on which to control the values selected.
 * 2. Syncs those values with internally stored `Option[]` state by querying our
 * Algolia search indices.
 * 3. Also exposes that `Option[]` state if needed by the parent component.
 */
export interface SelectControls<T, O extends Option<T> = Option<T>> {
  value: T[];
  onChange: (value: T[]) => void;
  selected: O[];
  onSelectedChange: (options: O[]) => void;
}

export type SelectControllerProps<T, O extends Option<T> = Option<T>> = Omit<
  SelectProps<T, O>,
  | keyof SelectControls<T, O>
  | 'getSuggestions'
  | 'noResultsMessage'
  | 'forceUpdateSuggestions'
> &
  Partial<SelectControls<T, O>>;

export default function Select<T, O extends Option<T>>({
  value,
  onChange,
  getSuggestions,
  forceUpdateSuggestions = false,
  noResultsMessage,
  renderToPortal = false,
  autoOpenMenu = false,
  singleLine = false,
  focused = false,
  onFocused = () => {},
  onBlurred = () => {},
  create,
  onCreate,
  className,
  ...textFieldProps
}: SelectProps<T, O>): JSX.Element {
  const suggestionsTimeoutId = useRef<ReturnType<typeof setTimeout>>();
  const foundationRef = useRef<MDCMenuSurfaceFoundation>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const ghostElementRef = useRef<HTMLSpanElement>(null);
  const lastSelectedRef = useRef<Option<T>>();
  const textareaBreakWidth = useRef<number>();
  const hasOpenedSuggestions = useRef<boolean>(false);

  const [suggestionsOpen, setSuggestionsOpen] = useState<boolean>(false);
  const [suggestions, setSuggestions] = useState<O[]>([]);
  const [inputValue, setInputValue] = useState<string>('');
  const [lineBreak, setLineBreak] = useState<boolean>(false);
  const [errored, setErrored] = useState<boolean>(false);

  const updateSuggestions = useCallback(
    async (search = '') => {
      const [err, options] = await to<O[]>(getSuggestions(search));
      if (err) {
        setSuggestions([]);
        setErrored(true);
      } else {
        setSuggestions(options as O[]);
        setErrored(false);
      }
    },
    [getSuggestions]
  );

  useEffect(() => {
    void updateSuggestions();
  }, [updateSuggestions]);

  /**
   * The `TextField`'s label should float if any of the following is true:
   * - The `TextField`'s value isn't empty.
   * - The `TextField` is focused.
   * - There are options selected (this is the only thing that's custom).
   *
   * Make sure to float the `TextField`'s label if there are options selected.
   * @see {@link https://github.com/jamesmfriedman/rmwc/issues/601}
   * @see {@link https://github.com/tutorbookapp/covid-tutoring/issues/8}
   */
  const emptyInputValue = useMemo(() => (value.length > 0 ? ' ' : ''), [value]);
  useEffect(() => {
    const isEmpty = inputValue === '' || inputValue === ' ';
    if (isEmpty && inputValue !== emptyInputValue)
      setInputValue(emptyInputValue);
  }, [inputValue, emptyInputValue]);

  /**
   * Ensure that the select menu is positioned correctly **even** if it's anchor
   * (the `TextField`) changes shape.
   * @see {@link https://github.com/jamesmfriedman/rmwc/issues/611}
   */
  useEffect(() => {
    (foundationRef.current as any)?.autoPosition_();
  });

  useEffect(() => {
    if (focused && inputRef.current) inputRef.current.focus();
  }, [focused]);

  const prevForceUpdateSuggestions = usePrevious(forceUpdateSuggestions);
  useEffect(() => {
    if (!forceUpdateSuggestions || prevForceUpdateSuggestions) return;
    void updateSuggestions();
  }, [updateSuggestions, forceUpdateSuggestions, prevForceUpdateSuggestions]);

  /**
   * We clear the timeout set by `this.closeSuggestions` to ensure that the
   * user doesn't get a blip where the suggestion select menu disappears and
   * reappears abruptly.
   * @see {@link https://bit.ly/2x9eM27}
   */
  const openSuggestions = useCallback(() => {
    if (suggestionsTimeoutId.current) {
      clearTimeout(suggestionsTimeoutId.current);
      suggestionsTimeoutId.current = undefined;
    }
    hasOpenedSuggestions.current = true;
    setSuggestionsOpen(true);
  }, []);
  const closeSuggestions = useCallback(() => {
    suggestionsTimeoutId.current = setTimeout(() => {
      setSuggestionsOpen(false);
      lastSelectedRef.current = undefined;
    }, 0);
  }, []);

  /**
   * We don't show the suggestion menu until after the user has started typing.
   * That way, the user learns that they can type to filter/search the options.
   * After they learn that (i.e. after the menu has been opened at least once),
   * we revert back to the original behavior (i.e. opening the menu whenever the
   * `TextField` input is focused).
   */
  const maybeOpenSuggestions = useCallback(() => {
    if (autoOpenMenu || hasOpenedSuggestions.current) openSuggestions();
  }, [autoOpenMenu, openSuggestions]);

  /**
   * This function pushes `<textarea>` to the next line when it's width is less
   * than the width of its text content.
   *
   * To measure the width of the content, the width of the invisible `<span>` is
   * used (to which the value of `<textarea>` is then assigned).
   */
  const updateInputLine = useCallback(
    (event: FormEvent<HTMLInputElement>) => {
      if (singleLine && ghostElementRef.current && inputRef.current) {
        ghostElementRef.current.innerText = event.currentTarget.value;
        inputRef.current.style.width = `${Math.ceil(
          ghostElementRef.current.clientWidth + 0.5
        )}px`;
      } else if (!singleLine && ghostElementRef.current) {
        ghostElementRef.current.innerText = event.currentTarget.value;

        if (
          ghostElementRef.current.clientWidth > event.currentTarget.clientWidth
        ) {
          textareaBreakWidth.current = event.currentTarget.clientWidth;
          setLineBreak(true);
        } else if (
          textareaBreakWidth.current &&
          ghostElementRef.current.clientWidth <= textareaBreakWidth.current
        ) {
          textareaBreakWidth.current = undefined;
          setLineBreak(false);
        }
      }
    },
    [singleLine]
  );

  /**
   * Workaround for styling the input as if it has content. If there are
   * options selected (in the given `options` object) and the `TextField`
   * would otherwise be empty, this will update the current input's value to a
   * string containing a space (`' '`) so that the `TextField` styles itself as
   * if it were filled. Otherwise, this acts as it normally would by updating
   * the `TextField`'s value using `setState`.
   * @see {@link https://github.com/jamesmfriedman/rmwc/issues/601}
   */
  const updateInputValue = useCallback(
    (event: FormEvent<HTMLInputElement>) => {
      updateInputLine(event);
      setInputValue(event.currentTarget.value);
      void updateSuggestions(event.currentTarget.value);
      openSuggestions();
    },
    [updateInputLine, updateSuggestions, openSuggestions]
  );

  /**
   * Selects or un-selects the given option string by setting it's value in
   * `this.state.selected` to `true` which:
   * 1. Checks it's corresponding `mdc-checkbox` within our drop-down menu.
   * 2. Adding it as a chip to the `mdc-text-field` content.
   */
  const updateSelected = useCallback(
    (option: O, event?: MouseEvent) => {
      const selected = Array.from(value);
      const selectedIndex = selected.findIndex((s) => s.value === option.value);
      if (selectedIndex < 0) {
        selected.push(option);
      } else {
        selected.splice(selectedIndex, 1);
      }

      if (suggestions.length && lastSelectedRef.current && event?.shiftKey) {
        // Select/unselect multiple options with 'SHIFT + click'
        const idx = suggestions.indexOf(option);
        const idxOfLast = suggestions.findIndex(
          (s) => s.value === (lastSelectedRef.current || {}).value
        );
        suggestions
          .slice(Math.min(idx, idxOfLast), Math.max(idx, idxOfLast) + 1)
          .forEach((suggestion) => {
            const index = selected.findIndex(
              (s) => s.value === suggestion.value
            );
            if (selectedIndex < 0 && index < 0) {
              selected.push(suggestion);
            } else if (selectedIndex >= 0 && index >= 0) {
              selected.splice(index, 1);
            }
          });
      }

      lastSelectedRef.current = option;

      onChange(selected);
      setInputValue((prev) => (selectedIndex < 0 ? '' : prev));
      setLineBreak(false);
    },
    [value, onChange, suggestions]
  );

  const { updateEl, removeEl } = useClickContext();
  const elementId = useRef<string>(`select-${nanoid()}`);
  const menuSurfaceRef = useCallback(
    (node: HTMLElement | null) => {
      if (!node) return removeEl(elementId.current);
      return updateEl(elementId.current, node);
    },
    [updateEl, removeEl]
  );

  return (
    <MenuSurfaceAnchor className={className}>
      <MenuSurface
        ref={menuSurfaceRef}
        foundationRef={foundationRef}
        open={suggestionsOpen}
        onFocus={(event: SyntheticEvent<HTMLDivElement>) => {
          event.preventDefault();
          event.stopPropagation();
          if (inputRef.current) inputRef.current.focus();
        }}
        anchorCorner='bottomStart'
        renderToPortal={renderToPortal ? '#portal' : false}
        className={!suggestions.length && !create ? styles.errMenu : ''}
      >
        <SelectSurface
          suggestions={suggestions}
          noResultsMessage={noResultsMessage}
          updateSelected={updateSelected}
          errored={errored}
          value={value}
          create={create}
          onCreate={onCreate}
        />
      </MenuSurface>
      <SelectHint open={suggestionsOpen}>
        <TextField
          {...textFieldProps}
          textarea
          inputRef={inputRef}
          value={inputValue}
          onFocus={() => {
            if (onFocused) onFocused();
            maybeOpenSuggestions();
          }}
          onBlur={() => {
            if (onBlurred) onBlurred();
            closeSuggestions();
          }}
          onChange={updateInputValue}
          className={styles.textField}
        >
          {value.map((opt) => (
            <Chip
              key={opt.key || nanoid()}
              label={opt.label}
              trailingIcon={<CloseIcon />}
              onTrailingIconInteraction={() => updateSelected(opt)}
              className={styles.chip}
            />
          ))}
          {lineBreak && <div className={styles.lineBreak} />}
          <span ref={ghostElementRef} className={styles.ghost} />
        </TextField>
      </SelectHint>
    </MenuSurfaceAnchor>
  );
}