18F/identity-idp

View on GitHub
app/javascript/packages/document-capture/components/file-input.tsx

Summary

Maintainability
A
0 mins
Test Coverage
import {
  useContext,
  useState,
  useMemo,
  useEffect,
  forwardRef,
  useRef,
  useImperativeHandle,
  ForwardedRef,
} from 'react';
import type {
  MouseEvent as ReactMouseEvent,
  DragEvent as ReactDragEvent,
  ChangeEvent as ReactChangeEvent,
  ReactNode,
} from 'react';
import { useI18n } from '@18f/identity-react-i18n';
import { SpinnerDots } from '@18f/identity-components';
import { useInstanceId } from '@18f/identity-react-hooks';
import FileImage from './file-image';
import StatusMessage, { Status } from './status-message';
import DeviceContext from '../context/device';
import usePrevious from '../hooks/use-previous';

interface FileInputProps {
  /**
   * Input label
   */
  label: string;

  /**
   * Optional hint text
   */
  hint?: string;

  /**
   * Banner overlay text
   */
  bannerText: string;

  /**
   * Error message text to show on invalid file type selection
   */
  invalidTypeText: string;

  /**
   * Success message text to show when selected file is updated
   */
  fileUpdatedText: string;

  /**
   * Status message text to show when file is pending
   */
  fileLoadingText: string;

  /**
   * Status message text to show once pending file is loaded
   */
  fileLoadedText: string;

  /**
   * Optional array of file input accept patterns
   */
  accept?: string[];

  /**
   * Current value
   */
  value: Blob | string | null | undefined;

  /**
   * Error to show
   */
  errorMessage?: ReactNode;

  /**
   * Whether to show the input in an indeterminate loading state,
   * pending an incoming value
   */
  isValuePending?: boolean;

  /**
   * Input click handler
   */
  onClick?: (event: ReactMouseEvent) => void;

  /**
   * Input drop handler
   */
  onDrop?: (event: ReactDragEvent) => void;

  /**
   * Input change handler
   */
  onChange?: (nextValue: File | null) => void;

  /**
   * Callback to trigger if upload error occurs
   */
  onError?: (message: ReactNode) => void;
}

type AriaLabelReturnType = { 'aria-labelledby': string } | { 'aria-label': string };

/**
 * Given a token of an file input accept attribute, returns an equivalent regular expression
 * pattern, or undefined if a pattern cannot be determined. This is an approximation, and not fully
 * spec-compliant to allowable characters in what is considered a valid MIME type.
 *
 * @see https://html.spec.whatwg.org/multipage/input.html#attr-input-accept
 * @see https://tools.ietf.org/html/rfc7231#section-3.1.1.1
 * @see https://tools.ietf.org/html/rfc2045#section-5.1
 *
 * @param {string} accept Accept token.
 *
 * @return {RegExp=} Regular expression, or undefined if cannot be determined.
 */
export function getAcceptPattern(accept: string): RegExp | undefined {
  switch (accept) {
    case 'audio/*':
    case 'video/*':
    case 'image/*': {
      const [type] = accept.split('/');
      return new RegExp(`^${type}/.+`);
    }

    default:
      return /^[\w-]+\/[\w-]+$/.test(accept) ? new RegExp(`^${accept}$`) : undefined;
  }
}

/**
 * Returns true if the given file represents an image, or false otherwise.
 *
 * @param {Blob|string} value File value to test.
 *
 * @return {boolean} Whether given file is an image.
 */
export function isImage(value: Blob | string): boolean {
  if (value instanceof window.Blob) {
    const pattern: RegExp | undefined = getAcceptPattern('image/*');
    if (pattern) {
      return pattern.test(value.type);
    }
    return false;
  }

  return /^data:image\//.test(value);
}

/**
 * Returns true if the given MIME type is valid for the array of accept tokens or if the accept
 * parameter is empty. Returns false otherwise.
 *
 * @param {string}    mimeType MIME type to test.
 * @param {string[]=} accept   Accept tokens.
 *
 * @return {boolean} Whether file is valid.
 */
export function isValidForAccepts(mimeType: string, accept?: string[]): boolean {
  return (
    !accept || accept.map(getAcceptPattern).some((pattern) => pattern && pattern.test(mimeType))
  );
}

interface AriaDescribedbyArguments {
  hint: string | undefined;
  hintId: string;
  shownErrorMessage: ReactNode | string | undefined;
  errorId: string;
  successMessage: string | undefined;
  successId: string;
}
function getAriaDescribedby({
  hint,
  hintId,
  shownErrorMessage,
  errorId,
  successMessage,
  successId,
}: AriaDescribedbyArguments) {
  // Error and success messages can't appear together, but either
  // error or success messages can appear with a hint message.
  const errorMessageShown = !!shownErrorMessage;
  const successMessageShown = !errorMessageShown && successMessage;
  const optionalHintId = hint ? hintId : undefined;

  if (errorMessageShown) {
    return optionalHintId ? `${errorId} ${optionalHintId}` : errorId;
  }
  if (successMessageShown) {
    return optionalHintId ? `${successId} ${optionalHintId}` : successId;
  }
  // if (!errorMessageShown && !successMessageShown) is the intent,
  // leaving it like this so it's also the default.
  return optionalHintId;
}

function FileInput(props: FileInputProps, ref: ForwardedRef<any>) {
  const {
    label,
    hint,
    bannerText,
    invalidTypeText,
    fileUpdatedText,
    fileLoadingText,
    fileLoadedText,
    accept,
    value,
    errorMessage,
    isValuePending,
    onClick,
    onDrop,
    onChange = () => {},
    onError = () => {},
  } = props;
  const inputRef = useRef<HTMLInputElement>(null);
  const { t, formatHTML } = useI18n();
  const instanceId = useInstanceId();
  const { isMobile } = useContext(DeviceContext);
  const [isDraggingOver, setIsDraggingOver] = useState(false);
  const previousValue = usePrevious(value);
  const previousIsValuePending = usePrevious(isValuePending);
  const isUpdated = useMemo(
    () => Boolean(previousValue && value && previousValue !== value),
    [value],
  );
  const isPendingValueReceived = useMemo(
    () => previousIsValuePending && !isValuePending && !!value,
    [value, isValuePending, previousIsValuePending],
  );
  const [ownErrorMessage, setOwnErrorMessage] = useState<string | null>(null);
  useMemo(() => setOwnErrorMessage(null), [value]);
  useImperativeHandle(ref, () => inputRef.current);
  useEffect(() => {
    // This is not a controlled component in the sense that the value is reflected onto the input
    // element. Clear any DOM value that happens to be set, so that the browser doesn't suppress a
    // change event based on what it assumes the current value to be.
    //
    // "In React, an <input type="file" /> is always an uncontrolled component because its value can
    // only be set by a user, and not programmatically."
    //
    // See: https://reactjs.org/docs/uncontrolled-components.html#the-file-input-tag
    if (inputRef.current && inputRef.current.files?.length) {
      inputRef.current.value = '';
    }
  }, [value]);
  const inputId = `file-input-${instanceId}`;
  const hintId = `${inputId}-hint`;
  const errorId = `${inputId}-error`;
  const successId = `${inputId}-success`;
  const innerHintId = `${hintId}-inner`;
  const labelId = `${inputId}-label`;
  const showInnerHint: boolean = !value && !isValuePending && !isMobile;
  // In test only we allow the upload of yaml files, but because they're text files
  // they don't have a preview. This shows the name of the file in the upload
  // box (using the existing preview) when the file name ends with .yml
  const isYAMLFile: boolean = value instanceof window.File && value.name.endsWith('.yml');
  const isIdCapture: boolean = !(label === t('doc_auth.headings.document_capture_selfie'));

  /**
   * In response to a file input change event, confirms that the file is valid before calling
   * `onChange`.
   */
  function onChangeIfValid(event: ReactChangeEvent<HTMLInputElement>) {
    if (!event.target.files) {
      return;
    }

    const file: File = event.target.files[0];
    if (file) {
      if (isValidForAccepts(file.type, accept)) {
        onChange(file);
      } else {
        const nextOwnErrorMessage = invalidTypeText;
        setOwnErrorMessage(nextOwnErrorMessage);
        onError(nextOwnErrorMessage);
      }
    } else {
      onChange(null);
    }
  }

  /**
   * @param {string} fileLabel String value of the label for input to display
   * @param {Blob|string|null|undefined} fileValue File or string for which to generate label.
   * @return {{'aria-label': string} | {'aria-labelledby': string}}
   */
  function getAriaLabelPropsFromValue(
    fileLabel: string,
    fileValue: Blob | string | null | undefined,
  ): AriaLabelReturnType {
    if (fileValue instanceof window.File) {
      return {
        'aria-label': `${fileLabel} - ${fileValue.name}`,
      };
    }

    if (fileValue) {
      return {
        'aria-label': `${fileLabel} - ${t('doc_auth.forms.captured_image')}`,
      };
    }

    // When no file is selected, provide a slightly more verbose label
    // including the actual <label> contents and the prompt to drag a file or
    // choose from a folder.
    if (showInnerHint) {
      return {
        'aria-labelledby': `${labelId} ${innerHintId}`,
      };
    }
    return {
      'aria-labelledby': `${labelId}`,
    };
  }

  const shownErrorMessage = errorMessage ?? ownErrorMessage;

  let successMessage: string | undefined;
  if (isUpdated) {
    successMessage = fileUpdatedText;
  } else if (isValuePending) {
    successMessage = fileLoadingText;
  } else if (isPendingValueReceived) {
    successMessage = fileLoadedText;
  }

  const ariaDescribedby = getAriaDescribedby({
    hint,
    hintId,
    shownErrorMessage,
    errorId,
    successMessage,
    successId,
  });

  return (
    <div
      className={[
        (shownErrorMessage || isUpdated) && 'usa-form-group',
        shownErrorMessage && 'usa-form-group--error',
        isUpdated && !shownErrorMessage && 'usa-form-group--success',
      ]
        .filter(Boolean)
        .join(' ')}
    >
      {/*
       * Disable reason: The Airbnb configuration of the `jsx-a11y` rule is strict in that it
       * requires _both_ the `for` attribute and nesting, to maximize support for assistive
       * technology. By the standard, only one or the other should be required. A form layout which
       * includes a hint following a label cannot be nested within the label without misidentifying
       * the hint as part of the label, which is the markup currently supported by USWDS.
       *
       * See: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/issues/718
       * See: https://github.com/airbnb/javascript/pull/2136
       */}
      {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
      <label
        id={labelId}
        htmlFor={inputId}
        className={['usa-label', shownErrorMessage && 'usa-label--error'].filter(Boolean).join(' ')}
      >
        {label}
      </label>
      {hint && (
        <span className="usa-hint" id={hintId}>
          {hint}
        </span>
      )}
      <StatusMessage status={Status.ERROR} id={errorId}>
        {shownErrorMessage}
      </StatusMessage>
      <StatusMessage
        id={successId}
        status={Status.SUCCESS}
        className={
          successMessage === fileLoadingText || successMessage === fileLoadedText
            ? 'usa-sr-only'
            : undefined
        }
      >
        {!shownErrorMessage && successMessage}
      </StatusMessage>
      <div
        className={[
          'usa-file-input usa-file-input--single-value',
          isDraggingOver && 'usa-file-input--drag',
          value && !isValuePending && 'usa-file-input--has-value',
          isValuePending && 'usa-file-input--value-pending',
          isIdCapture && 'usa-file-input--is-id-capture',
        ]
          .filter(Boolean)
          .join(' ')}
        onDragOver={() => setIsDraggingOver(true)}
        onDragLeave={() => setIsDraggingOver(false)}
        onDrop={() => setIsDraggingOver(false)}
      >
        <div className="usa-file-input__target">
          {value && !isValuePending && (!isMobile || isYAMLFile) && (
            <div className="usa-file-input__preview-heading">
              <span>
                {value instanceof window.File && (
                  <>
                    <span className="usa-sr-only">{t('doc_auth.forms.selected_file')}: </span>
                    {value.name}{' '}
                  </>
                )}
              </span>
              <span className="usa-file-input__choose">{t('doc_auth.forms.change_file')}</span>
            </div>
          )}
          {value && !isValuePending && isImage(value) && (
            <div className="usa-file-input__preview" aria-hidden="true">
              {value instanceof window.Blob ? (
                <FileImage file={value} alt="" className="usa-file-input__preview-image" />
              ) : (
                <img src={value} alt="" className="usa-file-input__preview-image" />
              )}
            </div>
          )}
          {!value && !isValuePending && (
            <div className="usa-file-input__instructions" aria-hidden="true">
              <strong className="usa-file-input__banner-text">{bannerText}</strong>
              {showInnerHint && (
                <span className="usa-file-input__drag-text" id={innerHintId}>
                  {formatHTML(t('doc_auth.forms.choose_file_html'), {
                    'lg-underline': ({ children }) => (
                      <span className="usa-file-input__choose">{children}</span>
                    ),
                  })}
                </span>
              )}
            </div>
          )}
          <div className="usa-file-input__box">
            {isValuePending && <SpinnerDots isCentered className="text-base" />}
          </div>
          <input
            ref={inputRef}
            id={inputId}
            className="usa-file-input__input"
            type="file"
            {...getAriaLabelPropsFromValue(label, value)}
            aria-busy={isValuePending}
            onChange={onChangeIfValid}
            onClick={onClick}
            onDrop={onDrop}
            accept={accept ? accept.join() : undefined}
            aria-describedby={ariaDescribedby}
          />
        </div>
      </div>
    </div>
  );
}

export default forwardRef(FileInput);