Source

stories/Typeahead.jsx

/** @module Typeahead */
import React, { forwardRef } from 'react';
import PropTypes from 'prop-types';

import { Box, TextField, Autocomplete, FormHelperText } from '@mui/material';
import AnyFieldLabel from './AnyFieldLabel';
import FormErrorMessage from './FormErrorMessage';
import { isEmpty } from '../helpers';

/**
 * Wrapper of the Mui Autocomplete component
 * Prevent closing by clicking outside of the dialog by NOT passing in a handleClose prop
 * We need to forward the ref to the Autocomplete component for use with react-hook-form
 * https://reactjs.org/docs/forwarding-refs.html
 * renderSX is styling for the surrounding box
 * sx is styling for the Autocomplete component
 * labelSX is styling for the label
 * textFieldSX is styling for the text field this is another MUI wrapper contains an input and the dropdown icon
 * inputLabelSX is styling for the input inside the textField
 * @function Typeahead
 * @param {object} props
 * @param {string} [props.label] - the label to display
 * @param {boolean} [props.isRequired] - is the field required
 * @param {object} [props.items] - the items to display in the dropdown
 * @param {object} [props.error] - the error message to display
 * @param {boolean} [props.disabled] - is the field disabled
 * @param {object} [props.renderSX] - the sx styling for the surrounding box
 * @param {object} [props.sx] - the sx styling for the Autocomplete component
 * @param {object} [props.labelSX] - the sx styling for the label
 * @param {object} [props.textFieldSX] - the sx styling for the text field
 * @param {object} [props.iconHelperText] - the text to display in the info icon
 * @param {object} [props.altHelperText] - helper text to display above the field
 * @param {object} [props.helperText] - the helper text to display (below the field)
 * @param {boolean} [props.multiple] - is the field a multi-select
 * @param {object} [props.fieldOptions] - the options to pass to the field
 * @param {object} [props.textFieldProps] - props to pass to the text field
 * @param {object} [props.textFieldProps.inputLabelProps] - props to pass to the input label
 * @param {object} [props.textFieldProps.inputProps] - props to pass to the input
 * @returns {React.ReactElement}
 */
const Typeahead = forwardRef(({ label, items, isRequired, textFieldProps, sx, error,
  disabled, renderSX, labelSX, inputSX, textFieldSX, iconHelperText, helperText, multiple,
  fieldOptions, altHelperText, ...props
}, ref) => {
  // Override the default Autocomplete getOptionLabel / getOptionSelected methods
  // We can override the override methods by passing in the same method name as a prop

  // See: https://material-ui.com/api/autocomplete/#getoptionlabel-item
  const getOptionLabel = (option) => {
    const foundOpt = getOpObj(option);
    // We need to return an empty string for the label for the place holder to correctly show
    return foundOpt?.label || foundOpt?.name || '';
  };

  // See: https://material-ui.com/api/autocomplete/#isOptionEqualToValue-item
  const isOptionEqualToValue = (option, value) => {
    /*
      There always must be a found option. In the event nothing matches one of our options
      we have to return true. This will "select" the placeholder / null option
      We use isEmpty because value of 0 is a valid value
    */
    if (isEmpty(value)) {
      return true;
    }

    const foundOpt = getOpObj(value);

    // Things will get strange if we don't have a found option at this point and you dun goofed A A Ron
    const noId = isEmpty(foundOpt?.id);
    const noValue = isEmpty(foundOpt?.value);
    const sameId = !noId && (foundOpt.id === option?.id);
    const sameValue = !noValue && (foundOpt.value === option?.value);
    const isEqual = foundOpt ? (sameId || sameValue) : true;
    return isEqual;
  };

  /**
   * Helper method to get the option object can either be an object or just the value of the id
   * @function getOpObj
   * @param {object} option
   * @returns {object} the option object
   */
  const getOpObj = (option) => {
    // Allow a value of 0 to be passed in
    if (isEmpty(option.id) && isEmpty(option.value)) {
      option = items.find(op => {
        const optValue = op?.id?.toString() || option?.value?.toString();
        return optValue === option?.toString();
      });
    }

    return option;
  };

  return (
    <Autocomplete
      ref={ref}
      sx={sx || { width: 300 }}
      options={items}
      multiple={multiple}
      autoHighlight
      disabled={disabled}
      getOptionLabel={getOptionLabel}
      isOptionEqualToValue={isOptionEqualToValue}
      renderOption={
        (optProps, option) => (
        // We're generating a more verbose key here in the event of bad data
          <Box component="li" {...optProps} key={`${option.id ?? option.value}-${option.label || option.name}`}>
            {option.label ?? option.name}
          </Box>
        )
      }
      renderInput={(params) => {
        return (
          <Box sx={renderSX || {}}>
            <AnyFieldLabel
              htmlFor={textFieldProps?.id || textFieldProps?.name || 'typeahead'}
              error={textFieldProps?.error}
              sx={labelSX || {}}
              label={label || 'Search'}
              required={!!isRequired}
              disabled={disabled}
              iconText={iconHelperText}
              fieldOptions={fieldOptions}
              helperText={altHelperText}
            />
            <TextField
              {...params}
              {...textFieldProps}
              sx={textFieldSX || {}}
              inputProps={{
                ...params.inputProps,
                sx: inputSX || {},
                autoComplete: 'new-password', // disable autocomplete and autofill
                'aria-autocomplete': 'none',
              }}
            />
            {helperText && <FormHelperText error={false}>{helperText}</FormHelperText>}
            <FormErrorMessage error={error} />
          </Box>
        );
      }}
      // Forward the rest of the props to the Autocomplete component
      // https://material-ui.com/api/autocomplete/#props
      {...props}
    />
  );
});

// Set the displayName to make it easier to identify in the React DevTools
// This needs to be set explicity because the forwardRef is used
// https://reactjs.org/docs/react-component.html#displayname
Typeahead.displayName = 'Typeahead';

Typeahead.propTypes = {
  disabled: PropTypes.bool,
  isRequired: PropTypes.bool,
  items: PropTypes.array,
  label: PropTypes.string,
  sx: PropTypes.object,
  helperText: PropTypes.string,
  multiple: PropTypes.bool,
  iconHelperText: PropTypes.string,
  altHelperText: PropTypes.string,
  error: PropTypes.object,
  fieldOptions: PropTypes.object,
  renderSX: PropTypes.object,
  labelSX: PropTypes.object,
  textFieldSX: PropTypes.object,
  inputSX: PropTypes.object,
  textFieldProps: PropTypes.object,
};

export default Typeahead;