DEV Community

Cover image for Building a Tag input using Set
Henrique Ramos
Henrique Ramos

Posted on • Edited on

Building a Tag input using Set

A tag input is a component that adds a new tag when a separator is typed (Usually spacebar or comma). It doesn't accept duplicates, which is really annoying to handle when using an array. Fret not, Set's here to help us.

Creating the Tag component

This one's a bit simple. A stateless component that receives two props: readOnly, that toggles the delete button display, and onRemove, which is a function to be called when that button is clicked. The final result looks like this:

interface TagProps {
  readOnly?: boolean;
  onRemove?: () => void;
}

const Tag: FC<TagProps> = ({ readOnly = false, onRemove, children }) => (
  <div className="tag">
    <small>{children}</small>
    {!readOnly && (
      <button onClick={onRemove}>
        <CloseIcon />
      </button>
    )}
  </div>
);
Enter fullscreen mode Exit fullscreen mode

Creating the input

Now here comes the fun part: the tag input itself. The component should: Render all tags and a text field, add a new tag when a certain separator character is typed, discard duplicate tags and, optionally, remove the latest tag by pressing backspace. After enlisting its expected behavior, things get way easier (That's why unit testing is so useful).

The foundation

Let's start from the beginning: Props. value (defaults to a new instance of Set) and onChange, making the component controllable, separator (Defaults to ",") which triggers the tag addition, and readOnly (defaults to false).

Next up, I'll create the component state. To control the tags, a Set is the perfect fit, since it assures that all values are unique, an array could also be used, but we would have to manually handle repeated tags. Coming next, we should add the text field, I'll remove all of its styling and style the parent div instead, since I want the final result to look like an usual input.

interface TagInputProps {
  onChange: (value: Set<string>) => void;
  value?: Set<string>;
  separator?: string;
  readOnly?: boolean;
}

const TagInput: FC<TagInputProps> = ({
  onChange,
  value = new Set(),
  separator = ",",
  readOnly = false
}) => {
  const [tags, setTags] = useState(value);
  const [inputValue, setInputValue] = useState("");

  return (
    <div className="tag-input">
      <input 
        value={inputValue} 
        onChange={({ target }) => setInputValue(target.value)}
      />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Adding tags

To effectively add tags to the state we're going to handle the onKeyDown event. If the pressed key (event.key) is equal to the separator and inputValue isn't empty, we'll add its value to tags. Translating into code:

const handleKeyDown = useCallback(
  (event) => {
    if (event.key === separator) {
      event.preventDefault();
      if (inputValue.trim().length > 0) {
        setTags((tags) => new Set([...tags, inputValue]));
        setInputValue("");
      }
    }
  },
  [separator, inputValue]
);
Enter fullscreen mode Exit fullscreen mode

Removing tags

Deleting a tag can be done in two ways: By clicking its remove button or by pressing backspace if the cursor
of the text field is in position 0.

Remove button

No mystery here. Just add a function that removes an item from the tags set to the onRemove prop on Tag component:

onRemove={() => {
  const updatedTags = new Set(tags);
  updatedTags.delete(tag);
  setTags((tags) => updatedTags);
}}
Enter fullscreen mode Exit fullscreen mode

Pressing backspace

Once again, we are going to visit handleKeyDown. First, we check if the prop backspaceErase is set to true, then if Backspace was pressed, then if the caret is at position 0, but how? By using selectionStart and selectionEnd from event.target: If no text is selected and cursor is at the start, both properties are going to be 0, now, handleKeyDown will look like this:

const handleKeyDown = useCallback(
  (event) => {
    if (event.key === separator) {
      event.preventDefault();
      if (inputValue.trim().length > 0) {
        setTags((tags) => new Set([...tags, inputValue]));
        setInputValue("");
      }
    }
    if (backspaceErase && event.key === "Backspace") {
      const { selectionStart, selectionEnd } = event.target;
      if (selectionStart === 0 && selectionEnd === 0) {
        setTags((tags) => new Set([...tags].slice(0, -1)));
      }
    }
  },
  [separator, inputValue]
);
Enter fullscreen mode Exit fullscreen mode

The result

Well, that was easier than I thought it would be. The result can be seen in the code sandbox below, where I also added a calcInputWidth function to dynamically resize the input to break lines only if the text couldn't fit on it. Cheers! See you in the next article!

Top comments (0)