grommet/grommet

View on GitHub
src/js/components/FileInput/FileInput.js

Summary

Maintainability
F
6 days
Test Coverage
import React, { forwardRef, useContext, useRef, useState } from 'react';
import styled, { ThemeContext } from 'styled-components';
import { CircleAlert } from 'grommet-icons/icons/CircleAlert';
import { MessageContext } from '../../contexts/MessageContext';

import { defaultProps } from '../../default-props';

import {
  disabledStyle,
  focusStyle,
  parseMetricToNum,
  unfocusStyle,
  useForwardedRef,
  useKeyboard,
} from '../../utils';

import { Anchor } from '../Anchor';
import { Box } from '../Box';
import { Button } from '../Button';
import { FormContext } from '../Form/FormContext';
import { Keyboard } from '../Keyboard';
import { Text } from '../Text';

import { StyledFileInput } from './StyledFileInput';
import { FileInputPropTypes } from './propTypes';
import { formatBytes } from './utils/formatBytes';
// We want the interaction of <input type="file" /> but none of its styling.
// So, we put what we want to show underneath and
// position the <input /> on top with an opacity of zero.
// If there are any files selected, we need to show the buttons to remove them.
// So, we offset the <input /> from the right by the appropriate width.
// We don't use Stack because of how we need to control the positioning.

const ContentsBox = styled(Box)`
  cursor: pointer;
  position: relative;
  ${(props) => props.disabled && disabledStyle()}
  ${(props) => props.theme.fileInput && props.theme.fileInput.extend};
  ${(props) =>
    props.hover &&
    props.theme.fileInput &&
    props.theme.fileInput.hover &&
    props.theme.fileInput.hover.extend};
  ${(props) =>
    props.dragOver &&
    props.theme.fileInput &&
    props.theme.fileInput.dragOver &&
    props.theme.fileInput.dragOver.extend};
  ${(props) => props.focus && focusStyle()};
  ${(props) => !props.focus && unfocusStyle()};
`;

const Label = styled(Text)`
  ${(props) =>
    props.theme.fileInput &&
    props.theme.fileInput.label &&
    props.theme.fileInput.label.extend};
`;

const Message = styled(Text)`
  ${(props) =>
    props.theme.fileInput &&
    props.theme.fileInput.message &&
    props.theme.fileInput.message.extend};
`;

const defaultPendingRemoval = {
  event: undefined,
  index: undefined,
};

const FileInput = forwardRef(
  (
    {
      a11yTitle,
      background,
      border,
      confirmRemove,
      disabled,
      id,
      plain,
      renderFile,
      maxSize,
      messages,
      margin,
      multiple,
      name,
      onChange,
      pad,
      value: valueProp,
      ...rest
    },
    ref,
  ) => {
    const theme = useContext(ThemeContext);
    const { format } = useContext(MessageContext);
    const formContext = useContext(FormContext);
    const [hover, setHover] = React.useState();
    const [dragOver, setDragOver] = React.useState();
    const [focus, setFocus] = React.useState();
    const [showRemoveConfirmation, setShowRemoveConfirmation] = useState(false);
    const [pendingRemoval, setPendingRemoval] = useState(defaultPendingRemoval);
    const aggregateThreshold = (multiple && multiple.aggregateThreshold) || 10;
    const max = multiple?.max;
    const inputRef = useForwardedRef(ref);
    const controlRef = useRef();
    const removeRef = useRef();
    const ConfirmRemove = confirmRemove;
    const RemoveIcon = theme.fileInput.icons.remove;
    const usingKeyboard = useKeyboard();

    const [files, setFiles] = formContext.useFormInput({
      name,
      value: valueProp,
      initialValue: [],
      validate: [
        maxSize
          ? () => {
              const fileList = [...files];
              let message = '';
              const numOfInvalidFiles = fileList.filter(
                ({ size }) => size > maxSize,
              ).length;
              if (numOfInvalidFiles) {
                let messageId = 'fileInput.maxSizeSingle';
                if (multiple) {
                  messageId = `fileInput.maxSizeMultiple.${
                    numOfInvalidFiles === 1 ? 'singular' : 'plural'
                  }`;
                }
                message = format({
                  id: messageId,
                  messages,
                  values: {
                    maxSize: formatBytes(maxSize),
                    numOfFiles: numOfInvalidFiles,
                  },
                });
              }
              return message;
            }
          : '',
        max
          ? () => {
              const fileList = [...files];
              let message = '';
              if (fileList.length > max) {
                message = format({
                  id: 'fileInput.maxFile',
                  messages,
                  values: { max },
                });
              }
              return message;
            }
          : '',
      ],
    });

    const mergeTheme = (propertyName, defaultKey) => {
      let result = {};
      const themeProp = theme.fileInput[propertyName];
      if (themeProp)
        if (typeof themeProp !== 'object')
          if (defaultKey) result[defaultKey] = themeProp;
          else result = themeProp;
        else result = { ...themeProp };
      const hoverThemeProp = theme.fileInput.hover[propertyName];
      if (hover && hoverThemeProp)
        if (typeof hoverThemeProp !== 'object')
          if (defaultKey) result[defaultKey] = hoverThemeProp;
          else result = hoverThemeProp;
        else result = { ...result, ...hoverThemeProp };
      const dragOverThemeProp = theme.fileInput.dragOver[propertyName];
      if (dragOver && dragOverThemeProp)
        if (typeof dragOverThemeProp !== 'object')
          if (defaultKey) result[defaultKey] = dragOverThemeProp;
          else result = dragOverThemeProp;
        else result = { ...result, ...dragOverThemeProp };
      return typeof result === 'object' && Object.keys(result).length === 0
        ? undefined
        : result;
    };

    let rightPad;
    if (mergeTheme('pad')) {
      const { horizontal, right } = mergeTheme('pad');
      if (right) {
        rightPad = theme.global.edgeSize[right] || right;
      } else if (horizontal) {
        rightPad = theme.global.edgeSize[horizontal] || horizontal;
      }
    }

    // rightPad needs to be included in the rightOffset
    // otherwise input may cover the RemoveButton, making it
    // unreachable by mouse click.
    // If browse anchor or button is greater than remove button then
    // rightoffset will take the larger width
    let rightOffset;
    if (removeRef.current && controlRef.current) {
      const rightOffsetBrowse =
        controlRef.current.getBoundingClientRect().width;
      const rightOffsetRemove = removeRef.current.getBoundingClientRect().width;
      if (rightPad && typeof rightPad === 'string')
        rightOffset = rightOffsetRemove + parseMetricToNum(rightPad);
      if (files.length === 1 || files.length > aggregateThreshold) {
        rightOffset =
          rightOffsetBrowse +
          rightOffsetRemove +
          parseMetricToNum(theme.global.edgeSize.small) * 2;
      } else if (rightOffsetBrowse > rightOffsetRemove) {
        rightOffset =
          rightOffsetBrowse + parseMetricToNum(theme.global.edgeSize.small) * 2;
      } else rightOffset = rightOffsetRemove;
    } else if (!files.length && controlRef.current) {
      rightOffset =
        controlRef.current.getBoundingClientRect().width +
        parseMetricToNum(theme.global.edgeSize.small) * 2;
    }

    // Show the number of files when more than one

    let message;
    if (!files.length) {
      message = format({
        id: multiple ? 'fileInput.dropPromptMultiple' : 'fileInput.dropPrompt',
        messages,
      });
    } else message = `${files.length} items`;

    const removeFile = (index) => {
      let nextFiles;
      if (index === 'all') {
        nextFiles = [];
      } else {
        nextFiles = [...files];
        nextFiles.splice(index, 1);
      }
      setFiles(nextFiles);

      // Need to have a way to track the files other than an array
      // since inputRef.current.files is a read-only FileList
      // https://stackoverflow.com/a/64019766
      /* eslint-disable no-undef */
      const dt = new DataTransfer();
      const curFiles = inputRef.current.files;
      if (index === 'all' || nextFiles.length === 0)
        inputRef.current.value = '';
      for (let i = 0; i < curFiles.length; i += 1) {
        const curfile = curFiles[i];
        if (index !== i) dt.items.add(curfile);
      }

      const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
        window.HTMLInputElement.prototype,
        'files',
      ).set;
      nativeInputValueSetter.call(inputRef.current, dt.files);
      const event = new Event('input', { bubbles: true });
      inputRef.current.dispatchEvent(event);

      if (onChange) onChange(event, { files: nextFiles });
      inputRef.current.focus();
    };

    return (
      <>
        <ContentsBox
          theme={theme}
          flex={false}
          disabled={disabled}
          background={mergeTheme('background', 'color')}
          border={!plain ? mergeTheme('border', 'side') : undefined}
          margin={mergeTheme('margin')}
          pad={mergeTheme('pad')}
          round={mergeTheme('round', 'size')}
          align={files.length ? 'stretch' : 'center'}
          justify="center"
          hover={hover}
          onMouseOver={disabled ? undefined : () => setHover(true)}
          onMouseOut={disabled ? undefined : () => setHover(false)}
          dragOver={dragOver}
          focus={usingKeyboard && focus}
        >
          <StyledFileInput
            ref={inputRef}
            type="file"
            id={id}
            name={name}
            maxSize={maxSize}
            multiple={multiple}
            disabled={disabled}
            plain
            rightOffset={rightOffset}
            {...rest}
            onDragOver={() => setDragOver(true)}
            onDragLeave={() => setDragOver(false)}
            onChange={(event) => {
              event.persist();
              const fileList = event.target.files;
              const nextFiles = multiple ? [...files] : [];
              for (let i = 0; i < fileList.length; i += 1) {
                // avoid duplicates
                const existing =
                  nextFiles.filter(
                    (file) =>
                      file.name === fileList[i].name &&
                      file.size === fileList[i].size,
                  ).length > 0;
                if (!existing) {
                  nextFiles.push(fileList[i]);
                }
              }
              setFiles(nextFiles);
              setDragOver(false);
              if (onChange) onChange(event, { files: nextFiles });
            }}
            onBlur={() => setFocus(false)}
            onFocus={() => setFocus(true)}
          />
          {(!files.length || files.length > 1) && (
            <Box
              align="center"
              fill="horizontal"
              direction="row"
              justify="between"
            >
              {files.length <= aggregateThreshold && (
                <>
                  <Message {...theme.fileInput.message}>{message}</Message>
                  <Keyboard
                    onSpace={(event) => {
                      event.preventDefault();
                      if (controlRef.current === event.target)
                        inputRef.current.click();
                    }}
                    onEnter={(event) => {
                      if (controlRef.current === event.target)
                        inputRef.current.click();
                    }}
                  >
                    {theme.fileInput.button ? (
                      <Button
                        // The focus here is redundant for keyboard users
                        tabIndex={-1}
                        ref={controlRef}
                        kind={theme.fileInput.button}
                        label={format({
                          id: 'fileInput.browse',
                          messages,
                        })}
                        onClick={() => {
                          inputRef.current.click();
                          inputRef.current.focus();
                        }}
                      />
                    ) : (
                      <Anchor
                        // The focus here is redundant for keyboard users
                        tabIndex={-1}
                        alignSelf="center"
                        ref={controlRef}
                        margin="small"
                        onClick={() => {
                          inputRef.current.click();
                          inputRef.current.focus();
                        }}
                        label={format({
                          id: 'fileInput.browse',
                          messages,
                        })}
                      />
                    )}
                  </Keyboard>
                </>
              )}
            </Box>
          )}
          {files.length > aggregateThreshold && (
            <Box justify="between" direction="row" align="center">
              <Label {...theme.fileInput.label}>
                {files.length}{' '}
                {format({
                  id: 'fileInput.files',
                  messages,
                })}
              </Label>
              <Box flex={false} direction="row" align="center">
                <Button
                  ref={removeRef}
                  a11yTitle={format({
                    id: 'fileInput.removeAll',
                    messages,
                  })}
                  icon={<RemoveIcon />}
                  hoverIndicator
                  onClick={(event) => {
                    if (confirmRemove) {
                      event.persist(); // necessary for when React < v17
                      setPendingRemoval({ event, index: 'all' });
                      setShowRemoveConfirmation(true);
                    } else removeFile('all');
                  }}
                />
                <Keyboard
                  onSpace={(event) => {
                    if (controlRef.current === event.target)
                      inputRef.current.click();
                  }}
                  onEnter={(event) => {
                    if (controlRef.current === event.target)
                      inputRef.current.click();
                  }}
                >
                  {theme.fileInput.button ? (
                    <Button
                      // The focus here is redundant for keyboard users
                      tabIndex={-1}
                      ref={controlRef}
                      kind={theme.fileInput.button}
                      label={format({
                        id: 'fileInput.browse',
                        messages,
                      })}
                      onClick={() => {
                        inputRef.current.click();
                        inputRef.current.focus();
                      }}
                    />
                  ) : (
                    <Anchor
                      // The focus here is redundant for keyboard users
                      tabIndex={-1}
                      alignSelf="center"
                      ref={controlRef}
                      margin="small"
                      onClick={() => {
                        inputRef.current.click();
                        inputRef.current.focus();
                      }}
                      label={format({
                        id: 'fileInput.browse',
                        messages,
                      })}
                    />
                  )}
                </Keyboard>
              </Box>
            </Box>
          )}
          {files.length > 0 &&
            files.length <= aggregateThreshold &&
            files.map((file, index) => (
              <Box
                key={file.name}
                justify="between"
                direction="row"
                align="center"
              >
                {renderFile ? (
                  renderFile(file)
                ) : (
                  <Box
                    {...theme.fileInput.label}
                    gap="xsmall"
                    align="center"
                    direction="row"
                  >
                    {((maxSize && file.size > maxSize) ||
                      (max && index >= max)) && <CircleAlert />}
                    <Label
                      weight={
                        theme.global.input.weight ||
                        theme.global.input.font.weight
                      }
                      truncate
                    >
                      {file.name}
                    </Label>
                  </Box>
                )}
                <Box flex={false} direction="row" align="center">
                  <Button
                    ref={index ? undefined : removeRef}
                    a11yTitle={`${format({
                      id: 'fileInput.remove',
                      messages,
                    })} ${file.name}`}
                    icon={<RemoveIcon />}
                    hoverIndicator
                    onClick={(event) => {
                      if (confirmRemove) {
                        event.persist(); // necessary for when React < v17
                        setPendingRemoval({ event, index });
                        setShowRemoveConfirmation(true);
                      } else removeFile(index);
                    }}
                  />
                  {files.length === 1 && (
                    <Keyboard
                      onSpace={(event) => {
                        if (controlRef.current === event.target)
                          inputRef.current.click();
                      }}
                      onEnter={(event) => {
                        if (controlRef.current === event.target)
                          inputRef.current.click();
                      }}
                    >
                      {theme.fileInput.button ? (
                        <Button
                          // The focus here is redundant for keyboard users
                          tabIndex={-1}
                          ref={controlRef}
                          kind={theme.fileInput.button}
                          label={format({
                            id: 'fileInput.browse',
                            messages,
                          })}
                          onClick={() => {
                            inputRef.current.click();
                            inputRef.current.focus();
                          }}
                        />
                      ) : (
                        <Anchor
                          // The focus here is redundant for keyboard users
                          tabIndex={-1}
                          ref={controlRef}
                          margin="small"
                          onClick={() => {
                            inputRef.current.click();
                            inputRef.current.focus();
                          }}
                          label={format({
                            id: 'fileInput.browse',
                            messages,
                          })}
                        />
                      )}
                    </Keyboard>
                  )}
                </Box>
              </Box>
            ))}
        </ContentsBox>
        {showRemoveConfirmation && (
          <ConfirmRemove
            onConfirm={() => {
              removeFile(pendingRemoval.index);
              setPendingRemoval(defaultPendingRemoval);
              setShowRemoveConfirmation(false);
            }}
            onCancel={() => setShowRemoveConfirmation(false)}
          />
        )}
      </>
    );
  },
);

FileInput.defaultProps = {};

Object.setPrototypeOf(FileInput.defaultProps, defaultProps);

FileInput.displayName = 'FileInput';
FileInput.propTypes = FileInputPropTypes;

export { FileInput };