prakhar1989/react-tags

View on GitHub
src/components/ReactTags.tsx

Summary

Maintainability
F
6 days
Test Coverage
import React, {
  useEffect,
  ReactNode,
  createRef,
  useRef,
  useState,
  Fragment,
} from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import noop from 'lodash/noop';
import uniq from 'lodash/uniq';
import ClearAllTags from './ClearAllTags';
import Suggestions from './Suggestions';
import ClassNames from 'classnames';
import { SingleTag } from './SingleTag';
import type { Tag } from './SingleTag';

import { buildRegExpFromDelimiters } from './utils';

//Constants
import {
  KEYS,
  DEFAULT_PLACEHOLDER,
  DEFAULT_CLASSNAMES,
  DEFAULT_LABEL_FIELD,
  INPUT_FIELD_POSITIONS,
  ERRORS,
} from './constants';

/**
 * Props for the ReactTags component.
 */
interface ReactTagsProps {
  /**
   * Placeholder text for the input field.
   */
  placeholder: string;
  /**
   * Field name to use for the label of each tag.
   */
  labelField: string;
  /**
   * Array of suggestions to display in the dropdown.
   */
  suggestions: Array<Tag>;
  /**
   * Array of key codes that will trigger a tag addition.
   */
  delimiters: Array<number>;
  /**
   * Whether the input field should automatically focus on mount.
   */
  autofocus: boolean;
  /**
   * Whether the input field should automatically focus on mount.
   */
  autoFocus: boolean;
  /**
   * Whether the input field should be read-only.
   */
  readOnly: boolean;
  /**
   * Whether the input field should be displayed inline.
   * TODO: Remove in v7.x.x
   */
  inline: boolean;
  /**
   * Position of the input field relative to the tags.
   */
  inputFieldPosition: keyof typeof INPUT_FIELD_POSITIONS;
  /**
   * Handler for tag deletion.
   */
  handleDelete: (
    i: number,
    event:
      | React.MouseEvent<HTMLSpanElement>
      | React.KeyboardEvent<HTMLSpanElement>
  ) => void;
  /**
   * Handler for tag addition.
   */
  handleAddition: (tag: { id: string }) => void;
  /**
   * Handler for tag updates.
   */
  onTagUpdate: (i: number, tag: { id: string }) => void;
  /**
   * Handler for tag drag and drop.
   */
  handleDrag: (tag: { id: string }, currPos: number, newPos: number) => void;
  /**
   * Handler for filtering suggestions.
   */
  handleFilterSuggestions: (
    query: string,
    suggestions: Array<Tag>
  ) => Array<Tag>;
  /**
   * Handler for tag click events.
   */
  handleTagClick?: (
    i: number,
    e: React.MouseEvent<HTMLSpanElement> | React.TouchEvent<HTMLSpanElement>
  ) => void;
  /**
   * Whether to allow deletion from an empty input field.
   */
  allowDeleteFromEmptyInput: boolean;
  /**
   * Whether to allow addition from pasted text.
   */
  allowAdditionFromPaste: boolean;
  /**
   * Whether to allow drag and drop of tags.
   */
  allowDragDrop: boolean;
  /**
   * Handler for input field changes.
   */
  handleInputChange: (
    value: string,
    event: React.ChangeEvent<HTMLInputElement>
  ) => void;
  /**
   * Handler for input field focus events.
   */
  handleInputFocus: (
    value: string,
    e: React.FocusEvent<HTMLInputElement>
  ) => void;
  /**
   * Handler for input field blur events.
   */
  handleInputBlur: (
    value: string,
    event: React.FocusEvent<HTMLInputElement>
  ) => void;
  /**
   * Minimum length of the query to trigger suggestions.
   */
  minQueryLength: number;
  /**
   * Function to determine whether to render suggestions.
   */
  shouldRenderSuggestions: (query: string) => boolean;
  /**
   * Component to be rendered for removing tags.
   */
  removeComponent: React.ComponentType<any>;
  /**
   * Whether to enable autocomplete when typing for suggestions
   */
  autocomplete: boolean | number;
  /**
   * CSS class names for the component.
   */
  classNames: {
    root: string;
    rootFocused: string;
    selected: string;
    selectedTag: string;
    selectedTagName: string;
    search: string;
    searchInput: string;
    suggestions: string;
    suggestionActive: string;
    suggestionDisabled: string;
  };
  /**
   * Name attribute for the input field.
   */
  name: string;
  /**
   * ID attribute for the input field.
   */
  id: string;
  /**
   * Maximum length of the input field.
   */
  maxLength: number;
  /**
   * Value of the input field.
   */
  inputValue: string;
  /**
   * Maximum number of tags that can be added.
   */
  maxTags: number;
  /**
   * Array of tags to display.
   */
  tags: Array<Tag>;
  /**
   * Whether to allow unique tags only.
   */
  allowUnique: boolean;
  /**
   * Render function to render each suggestion item.
   */
  renderSuggestion: (item: Tag, query: string) => ReactNode;
  /**
   * Additional props to pass to the input field.
   */
  inputProps: { [key: string]: string };
  /**
   * Whether the tags are editable.
   */
  editable: boolean;
  /**
   * Whether to display a button to clear all the tags.
   */
  clearAll: boolean;
  /**
   * Handler for clearing all the tags.
   */
  onClearAll: () => void;
}

interface ReactTagsState {
  /**
   * Array of suggestions to display in the dropdown.
   */
  suggestions: Array<Tag>;
  /**
   * Query string to filter suggestions.
   */
  query: string;
  /**
   * Whether the input field is focused.
   */
  isFocused: boolean;
  /**
   * Index of the selected suggestion.
   */
  selectedIndex: number;
  /**
   * Whether the input field is in selection mode.
   */
  selectionMode: boolean;
  /**
   * ARIA live status message.
   */
  ariaLiveStatus: string;
  /**
   * Index of the tag currently being edited.
   */
  currentEditIndex: number;
  /**
   * Error message to display.
   */
  error: string;
}

const ReactTags = (props: ReactTagsProps) => {
  const {
    autofocus,
    autoFocus,
    readOnly,
    labelField,

    allowDeleteFromEmptyInput,
    allowAdditionFromPaste,
    allowDragDrop,

    minQueryLength,
    shouldRenderSuggestions,
    removeComponent,
    autocomplete,
    inline,
    maxTags,
    allowUnique,
    editable,

    placeholder,
    delimiters,
    tags,
    inputFieldPosition,
    inputProps,
    classNames,
    maxLength,
    inputValue,
    clearAll,
  } = props;

  const [suggestions, setSuggestions] = useState(props.suggestions);
  const [query, setQuery] = useState('');
  const [isFocused, setIsFocused] = useState(false);
  const [selectedIndex, setSelectedIndex] = useState(-1);
  const [selectionMode, setSelectionMode] = useState(false);
  const [ariaLiveStatus, setAriaLiveStatus] = useState('');
  const [currentEditIndex, setCurrentEditIndex] = useState(-1);
  const [error, setError] = useState('');

  const reactTagsRef = createRef<HTMLDivElement>();
  const textInput = useRef<HTMLInputElement | null>(null);
  const tagInput = useRef<HTMLInputElement | null>(null);

  useEffect(() => {
    if (!inline) {
      console.warn(
        '[Deprecation] The inline attribute is deprecated and will be removed in v7.x.x, please use inputFieldPosition instead.'
      );
    }
  }, [inline]);

  useEffect(() => {
    if (autofocus === false) {
      console.warn(
        '[Deprecated] autofocus prop will be removed in 7.x so please migrate to autoFocus prop.'
      );
    }
    if (autofocus && autoFocus && !readOnly) {
      resetAndFocusInput();
    }
  }, [autoFocus, autoFocus, readOnly]);

  useEffect(() => {
    updateSuggestions();
  }, [query, props.suggestions]);

  // Filter suggestions based on the query and existing tags
  const filteredSuggestions = (query: string) => {
    let updatedSuggestions = props.suggestions.slice();

    // If unique tags are allowed, filter out suggestions that are already in the tags array
    if (allowUnique) {
      const existingTags = tags.map((tag) => tag.id.trim().toLowerCase());
      updatedSuggestions = updatedSuggestions.filter(
        (suggestion) => !existingTags.includes(suggestion.id.toLowerCase())
      );
    }
    // If a custom filter function is provided, use it to filter the suggestions
    if (props.handleFilterSuggestions) {
      return props.handleFilterSuggestions(query, updatedSuggestions);
    }

    // Separate exact matches and partial matches and return them concatenated
    // so that exact matches are always displayed first followed by partial
    const exactSuggestions = updatedSuggestions.filter(
      (item) => getQueryIndex(query, item) === 0
    );
    const partialSuggestions = updatedSuggestions.filter(
      (item) => getQueryIndex(query, item) > 0
    );

    return exactSuggestions.concat(partialSuggestions);
  };

  const getQueryIndex = (query: string, item: Tag) => {
    return item[labelField].toLowerCase().indexOf(query.toLowerCase());
  };

  const resetAndFocusInput = () => {
    setQuery('');
    if (!textInput.current) {
      return;
    }
    textInput.current.value = '';
    textInput.current.focus();
  };

  const handleDelete = (
    index: number,
    event:
      | React.MouseEvent<HTMLSpanElement>
      | React.KeyboardEvent<HTMLSpanElement>
  ) => {
    event.preventDefault();
    event.stopPropagation();
    const currentTags = tags.slice();
    // Early exit from the function if the array
    // is already empty
    if (currentTags.length === 0) {
      return;
    }
    setError('');
    props.handleDelete(index, event);
    updateAriaLiveStatus(index, currentTags);
  };

  const updateAriaLiveStatus = (index: number, tags: Tag[]) => {
    if (!reactTagsRef?.current) {
      return;
    }
    const tagRemoveButtons: NodeListOf<HTMLButtonElement> =
      reactTagsRef.current.querySelectorAll('.ReactTags__remove');
    let ariaLiveStatus = '';

    if (index === 0 && tags.length > 1) {
      ariaLiveStatus = `Tag at index ${index} with value ${tags[index].id} deleted. Tag at index 0 with value ${tags[1].id} focussed. Press backspace to remove`;
      tagRemoveButtons[0].focus();
    } else if (index > 0) {
      ariaLiveStatus = `Tag at index ${index} with value ${
        tags[index].id
      } deleted. Tag at index ${index - 1} with value ${
        tags[index - 1].id
      } focussed. Press backspace to remove`;
      tagRemoveButtons[index - 1].focus();
    } else {
      ariaLiveStatus = `Tag at index ${index} with value ${tags[index].id} deleted. Input focussed. Press enter to add a new tag`;
      textInput.current?.focus();
    }

    setAriaLiveStatus(ariaLiveStatus);
  };

  const handleTagClick = (
    index: number,
    tag: Tag,
    event: React.MouseEvent<HTMLSpanElement> | React.TouchEvent<HTMLSpanElement>
  ) => {
    if (readOnly) {
      return;
    }
    if (editable) {
      setCurrentEditIndex(index);
      setQuery(tag[labelField]);
      tagInput.current?.focus();
    }
    props.handleTagClick?.(index, event);
  };

  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    if (props.handleInputChange) {
      props.handleInputChange(event.target.value, event);
    }

    const query = event.target.value.trim();
    setQuery(query);
  };

  const updateSuggestions = () => {
    const newSuggestions = filteredSuggestions(query);
    setSuggestions(newSuggestions);
    setSelectedIndex(
      selectedIndex >= newSuggestions.length
        ? newSuggestions.length - 1
        : selectedIndex
    );
  };

  const handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
    const value = event.target.value;
    if (props.handleInputFocus) {
      props.handleInputFocus(value, event);
    }
    setIsFocused(true);
  };

  const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
    const value = event.target.value;
    if (props.handleInputBlur) {
      props.handleInputBlur(value, event);
      if (textInput.current) {
        textInput.current.value = '';
      }
    }
    setIsFocused(false);
    setCurrentEditIndex(-1);
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    // hide suggestions menu on escape
    if (event.keyCode === KEYS.ESCAPE) {
      event.preventDefault();
      event.stopPropagation();
      setSelectedIndex(-1);
      setSelectionMode(false);
      setSuggestions([]);
      setCurrentEditIndex(-1);
    }

    // When one of the terminating keys is pressed, add current query to the tags.
    // If no text is typed in so far, ignore the action - so we don't end up with a terminating
    // character typed in.
    if (delimiters.indexOf(event.keyCode) !== -1 && !event.shiftKey) {
      if (event.keyCode !== KEYS.TAB || query !== '') {
        event.preventDefault();
      }

      const selectedQuery =
        selectionMode && selectedIndex !== -1
          ? suggestions[selectedIndex]
          : {
              id: query.trim(),
              [labelField]: query.trim(),
              className: '',
            };
      if (Object.keys(selectedQuery)) {
        addTag(selectedQuery);
      }
    }
    // If the backspace key is pressed and the query is empty, delete the last tag if
    // allowDeleteFromEmptyInput is true or if the input field is inline
    if (
      event.key === 'Backspace' &&
      query === '' &&
      (allowDeleteFromEmptyInput ||
        inputFieldPosition === INPUT_FIELD_POSITIONS.INLINE)
    ) {
      handleDelete(tags.length - 1, event);
    }

    // up arrow
    if (event.keyCode === KEYS.UP_ARROW) {
      event.preventDefault();
      setSelectedIndex(
        selectedIndex <= 0 ? suggestions.length - 1 : selectedIndex - 1
      );
      setSelectionMode(true);
    }

    // down arrow
    if (event.keyCode === KEYS.DOWN_ARROW) {
      event.preventDefault();
      setSelectionMode(true);
      suggestions.length === 0
        ? setSelectedIndex(-1)
        : setSelectedIndex((selectedIndex + 1) % suggestions.length);
    }
  };

  const tagLimitReached = () => {
    return maxTags && tags.length >= maxTags;
  };

  const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
    if (!allowAdditionFromPaste) {
      return;
    }

    if (tagLimitReached()) {
      setError(ERRORS.TAG_LIMIT);
      resetAndFocusInput();
      return;
    }

    setError('');

    event.preventDefault();

    const clipboardData = event.clipboardData || (window as any).clipboardData;
    const clipboardText = clipboardData.getData('text');

    const { maxLength = clipboardText.length } = props;

    const maxTextLength = Math.min(maxLength, clipboardText.length);
    const pastedText = clipboardData.getData('text').substr(0, maxTextLength);

    // Used to determine how the pasted content is split.
    const delimiterRegExp = buildRegExpFromDelimiters(delimiters);
    const tags = pastedText.split(delimiterRegExp).map((tag) => tag.trim());

    // Only add unique tags
    uniq(tags).forEach((tag) =>
      addTag({
        id: tag.trim(),
        [labelField]: tag.trim(),
        className: '',
      })
    );
  };

  const addTag = (tag: Tag) => {
    if (!tag.id || !tag[labelField]) {
      return;
    }

    if (currentEditIndex === -1) {
      if (tagLimitReached()) {
        setError(ERRORS.TAG_LIMIT);
        resetAndFocusInput();
        return;
      }
      setError('');
    }

    const existingKeys = tags.map((tag: Tag) => tag.id.toLowerCase());

    // Return if tag has been already added
    if (allowUnique && existingKeys.indexOf(tag.id.trim().toLowerCase()) >= 0) {
      return;
    }
    if (autocomplete) {
      const possibleMatches = filteredSuggestions(tag[labelField]);
      console.warn(
        '[Deprecation] The autocomplete prop will be removed in 7.x to simplify the integration and make it more intutive. If you have any concerns regarding this, please share your thoughts in https://github.com/react-tags/react-tags/issues/949'
      );
      if (
        (autocomplete === 1 && possibleMatches.length === 1) ||
        (autocomplete === true && possibleMatches.length)
      ) {
        tag = possibleMatches[0];
      }
    }

    // call method to add
    if (currentEditIndex !== -1 && props.onTagUpdate)
      props.onTagUpdate(currentEditIndex, tag);
    else props.handleAddition(tag);

    // reset the state
    setQuery('');
    setSelectionMode(false);
    setSelectedIndex(-1);
    setCurrentEditIndex(-1);

    resetAndFocusInput();
  };

  const handleSuggestionClick = (index: number) => {
    addTag(suggestions[index]);
  };

  const handleClearAll = () => {
    if (props.onClearAll) {
      props.onClearAll();
    }
    setError('');
    resetAndFocusInput();
  };

  const handleSuggestionHover = (index: number) => {
    setSelectedIndex(index);
    setSelectionMode(true);
  };

  const moveTag = (dragIndex: number, hoverIndex: number) => {
    // locate tags
    const dragTag = tags[dragIndex];

    // call handler with the index of the dragged tag
    // and the tag that is hovered
    props.handleDrag(dragTag, dragIndex, hoverIndex);
  };

  const getTagItems = () => {
    const allClassNames = { ...DEFAULT_CLASSNAMES, ...props.classNames };

    return tags.map((tag, index) => {
      return (
        <Fragment key={index}>
          {currentEditIndex === index ? (
            <div className={allClassNames.editTagInput}>
              <input
                ref={(input: HTMLInputElement) => {
                  tagInput.current = input;
                }}
                onFocus={handleFocus}
                value={query}
                onChange={handleChange}
                onKeyDown={handleKeyDown}
                onBlur={handleBlur}
                className={allClassNames.editTagInputField}
                onPaste={handlePaste}
                data-testid="tag-edit"
              />
            </div>
          ) : (
            <SingleTag
              index={index}
              tag={tag}
              labelField={labelField}
              onDelete={(
                event:
                  | React.MouseEvent<HTMLSpanElement>
                  | React.KeyboardEvent<HTMLSpanElement>
              ) => handleDelete(index, event)}
              moveTag={allowDragDrop ? moveTag : undefined}
              removeComponent={removeComponent}
              onTagClicked={(
                event:
                  | React.MouseEvent<HTMLSpanElement>
                  | React.TouchEvent<HTMLSpanElement>
              ) => handleTagClick(index, tag, event)}
              readOnly={readOnly}
              classNames={allClassNames}
              allowDragDrop={allowDragDrop}
            />
          )}
        </Fragment>
      );
    });
  };

  const tagItems = getTagItems();
  const allClassNames = { ...DEFAULT_CLASSNAMES, ...classNames };

  const { name: inputName, id: inputId } = props;

  const position = !inline ? INPUT_FIELD_POSITIONS.BOTTOM : inputFieldPosition;

  const tagsComponent = !readOnly ? (
    <div className={allClassNames.tagInput}>
      <input
        {...inputProps}
        ref={(input) => {
          textInput.current = input;
        }}
        className={allClassNames.tagInputField}
        type="text"
        placeholder={placeholder}
        aria-label={placeholder}
        onFocus={handleFocus}
        onBlur={handleBlur}
        onChange={handleChange}
        onKeyDown={handleKeyDown}
        onPaste={handlePaste}
        name={inputName}
        id={inputId}
        maxLength={maxLength}
        value={inputValue}
        data-automation="input"
        data-testid="input"
      />

      <Suggestions
        query={query.trim()}
        suggestions={suggestions}
        labelField={labelField}
        selectedIndex={selectedIndex}
        handleClick={handleSuggestionClick}
        handleHover={handleSuggestionHover}
        minQueryLength={minQueryLength}
        shouldRenderSuggestions={shouldRenderSuggestions}
        isFocused={isFocused}
        classNames={allClassNames}
        renderSuggestion={props.renderSuggestion}
      />
      {clearAll && tags.length > 0 && (
        <ClearAllTags classNames={allClassNames} onClick={handleClearAll} />
      )}
      {error && (
        <div data-testid="error" className="ReactTags__error">
          <svg
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 512 512"
            height="24"
            width="24"
            fill="#e03131">
            <path d="M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480H40c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24V296c0 13.3 10.7 24 24 24s24-10.7 24-24V184c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z" />
          </svg>
          {error}
        </div>
      )}
    </div>
  ) : null;

  return (
    <div
      className={ClassNames(allClassNames.tags, 'react-tags-wrapper')}
      ref={reactTagsRef}>
      <p
        role="alert"
        className="sr-only"
        style={{
          position: 'absolute',
          overflow: 'hidden',
          clip: 'rect(0 0 0 0)',
          margin: '-1px',
          padding: 0,
          width: '1px',
          height: '1px',
          border: 0,
        }}>
        {ariaLiveStatus}
      </p>
      {position === INPUT_FIELD_POSITIONS.TOP && tagsComponent}
      <div className={allClassNames.selected}>
        {tagItems}
        {position === INPUT_FIELD_POSITIONS.INLINE && tagsComponent}
      </div>
      {position === INPUT_FIELD_POSITIONS.BOTTOM && tagsComponent}
    </div>
  );
};

ReactTags.defaultProps = {
  placeholder: DEFAULT_PLACEHOLDER,
  labelField: DEFAULT_LABEL_FIELD,
  suggestions: [],
  delimiters: [...KEYS.ENTER, KEYS.TAB],
  autofocus: true,
  autoFocus: true,
  inline: true, // TODO: Remove in v7.x.x
  inputFieldPosition: INPUT_FIELD_POSITIONS.INLINE,
  handleDelete: noop,
  handleAddition: noop,
  allowDeleteFromEmptyInput: false,
  allowAdditionFromPaste: true,
  autocomplete: false,
  readOnly: false,
  allowUnique: true,
  allowDragDrop: true,
  tags: [],
  inputProps: {},
  onTagUpdate: noop,
  editable: false,
  clearAll: false,
  handleClearAll: noop,
};
const WithContext = ({ ...props }: ReactTagsProps) => (
  // @ts-ignore
  <DndProvider backend={HTML5Backend}>
    <ReactTags {...props} />
  </DndProvider>
);
export { WithContext };
export { ReactTags as WithOutContext };
export { KEYS };