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>
);
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>
);
};
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]
);
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);
}}
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]
);
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)