Skip to content

Instantly share code, notes, and snippets.

@enesien
Last active October 4, 2024 03:10
Show Gist options
  • Save enesien/03ba5340f628c6c812b306da5fedd1a4 to your computer and use it in GitHub Desktop.
Save enesien/03ba5340f628c6c812b306da5fedd1a4 to your computer and use it in GitHub Desktop.
shadcn multiple tag input

shadcn/ui multi tag input component

A react tag input field component using shadcn, like one you see when adding keywords to a video on YouTube. Usable with Form or standalone.

Preview

image

Standalone Usage

const [values, setValues] = useState<string[]>([])
...
<InputTags value={values} onChange={setValues} />

Form Usage (React Hook Form)

<FormField
  control={form.control}
  name="data_points"
  render={({ field }) => (
    <FormItem>
      <FormLabel>Add Data Point(s)</FormLabel>
      <FormControl>
        <InputTags {...field} />
      </FormControl>
      <FormDescription>
       ...
      </FormDescription>
      <FormMessage />
    </FormItem>
  )}
/>

Component

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input, InputProps } from "@/components/ui/input";
import { XIcon } from "lucide-react";
import { Dispatch, SetStateAction, forwardRef, useState } from "react";

type InputTagsProps = InputProps & {
  value: string[];
  onChange: Dispatch<SetStateAction<string[]>>;
};

export const InputTags = forwardRef<HTMLInputElement, InputTagsProps>(
  ({ value, onChange, ...props }, ref) => {
    const [pendingDataPoint, setPendingDataPoint] = useState("");

    const addPendingDataPoint = () => {
      if (pendingDataPoint) {
        const newDataPoints = new Set([...value, pendingDataPoint]);
        onChange(Array.from(newDataPoints));
        setPendingDataPoint("");
      }
    };

    return (
      <>
        <div className="flex">
          <Input
            value={pendingDataPoint}
            onChange={(e) => setPendingDataPoint(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === "Enter") {
                e.preventDefault();
                addPendingDataPoint();
              } else if (e.key === "," || e.key === " ") {
                e.preventDefault();
                addPendingDataPoint();
              }
            }}
            className="rounded-r-none"
            {...props}
            ref={ref}
          />
          <Button
            type="button"
            variant="secondary"
            className="rounded-l-none border border-l-0"
            onClick={addPendingDataPoint}
          >
            Add
          </Button>
        </div>
        <div className="border rounded-md min-h-[2.5rem] overflow-y-auto p-2 flex gap-2 flex-wrap items-center">
          {value.map((item, idx) => (
            <Badge key={idx} variant="secondary">
              {item}
              <button
                type="button"
                className="w-3 ml-2"
                onClick={() => {
                  onChange(value.filter((i) => i !== item));
                }}
              >
                <XIcon className="w-3" />
              </button>
            </Badge>
          ))}
        </div>
      </>
    );
  }
);

Godspeed!

Created by Enesien

@enesien
Copy link
Author

enesien commented Oct 13, 2023

Nice thank you appreciate your work alot !

I am using Server Actions, with html/tailwind validation, and the Formdata. It would be nice. If you could add a Server Action version.

In the moment i just use a standart html select with multiple attributes. But cant get it pretty accessible enough to fit with the shadcn style.

Thanks!

Don't you just call the server action from the submit handler function wrapped in startTransition?

const onSubmit = async (values: z.infer<typeof schema>) => {
  startTransition(() => {
    someServerAction(values);
  });
}

@agreen254
Copy link

Great work @enesien! Helped me better understand forwarding refs with react hook form.

@puneetv05
Copy link

@enesien Thank you :)

@Ritiksh0h
Copy link

Great work! Just add
InputTags.displayName = "InputTags"; export default InputTags;
at the end of component;; otherwise, it will give error Error: Component definition is missing display name react/display-name

@Kavindu-Wijesekara
Copy link

@enesien thank you <3

@algsupport
Copy link

algsupport commented Aug 8, 2024

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input, InputProps } from "@/components/ui/input";
import { PencilIcon, XIcon } from "lucide-react";
import {
    ChangeEvent,
    forwardRef,
    useImperativeHandle,
    useRef,
    useState,
} from "react";

type InputTagsProps = InputProps & {
    value: string[];
    onChange: (data: string[] | ChangeEvent<HTMLInputElement>) => void;
    onError?: (error: string) => void;
    validator?: (value: string) => boolean;
};

export const InputTags = forwardRef<HTMLInputElement, InputTagsProps>(
    ({ value, onChange, onError, validator = () => true, ...props }, ref) => {
        const [pendingDataPoint, setPendingDataPoint] = useState("");
        const inputRef = useRef<HTMLInputElement>(null);
        useImperativeHandle(ref, () => inputRef.current!);

        const addPendingDataPoint = () => {
          if (pendingDataPoint && validator(pendingDataPoint)) {
              const newDataPoints = new Set([...value, pendingDataPoint]);
              onChange(Array.from(newDataPoints));
              setPendingDataPoint("");
              if (onError) onError(""); // Clear any previous error
          } else if (!validator(pendingDataPoint)) {
              if (onError) onError("Invalid input: " + pendingDataPoint);
            }
      };

        return (
            <div className="flex flex-col gap-2">
                <div className="flex">
                    <Input
                        value={pendingDataPoint}
                        onChange={(e) => setPendingDataPoint(e.target.value)}
                        onKeyDown={(e) => {
                            if (e.key === "Enter") {
                                e.preventDefault();
                                addPendingDataPoint();
                            } else if (e.key === "," || e.key === " ") {
                                e.preventDefault();
                                addPendingDataPoint();
                            }
                        }}
                        className="rounded-r-none"
                        {...props}
                        ref={inputRef}
                    />
                    <Button
                        type="button"
                        variant="secondary"
                        className="rounded-l-none border border-l-0"
                        onClick={addPendingDataPoint}
                    >
                        Add
                    </Button>
                </div>
                <div className="border rounded-md min-h-[2.5rem] overflow-y-auto p-2 flex gap-2 flex-wrap items-center">
                    {value.map((item, idx) => (
                        <Badge key={idx} variant="secondary">
                            {item}
                            <button
                                type="button"
                                className="w-3 ml-2"
                                onClick={() => {
                                    onChange(value.filter((i) => i !== item));
                                }}
                            >
                                <XIcon className="w-3" />
                            </button>
                            <button
                                type="button"
                                className="w-3 ml-2"
                                onClick={() => {
                                    setPendingDataPoint(item);
                                    onChange(value.filter((i) => i !== item));
                                    if (inputRef.current) {
                                        inputRef.current.focus();
                                    }
                                }}
                            >
                                <PencilIcon className="w-3" />
                            </button>
                        </Badge>
                    ))}
                </div>
            </div>
        );
    }
);


Excellent work @enesien I have added some changes if you wish to incorporate them.

I have added the ability to edit the inputs and also you can add an optional validator to validate the inputs.

example validator and usage:

Validator:

// Example IPv4 Validator
const ipv4Validator = (value: string) => {
    const ipv4Pattern =
        /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
    return ipv4Pattern.test(value);
};

Usage with validator:

const [values, setValues] = useState<string[]>([])
...
<InputTags  validator={ipv4Validator} value={values} onChange={setValues} />

I have also added an orError callback in order to handle validation errors.

@aryanbhat
Copy link

aryanbhat commented Aug 10, 2024

I made some changes to the component so that the box displaying the selected values only appears when there are values present. Previously, the box was always visible, even when empty. Now, the box will only show up when there are items to display, making the UI cleaner and more adaptable to your specific use case

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input, InputProps } from "@/components/ui/input";
import { Cross2Icon } from "@radix-ui/react-icons";
import { Dispatch, SetStateAction, forwardRef, useState } from "react";

type InputTagsProps = InputProps & {
  value: string[];
  onChange: Dispatch<SetStateAction<string[]>>;
};

export const InputTags = forwardRef<HTMLInputElement, InputTagsProps>(
  ({ value, onChange, ...props }, ref) => {
    const [pendingDataPoint, setPendingDataPoint] = useState("");

    const addPendingDataPoint = () => {
      if (pendingDataPoint) {
        const newDataPoints = new Set([...value, pendingDataPoint]);
        onChange(Array.from(newDataPoints));
        setPendingDataPoint("");
      }
    };

    return (
      <>
        <div className="flex">
          <Input
            value={pendingDataPoint}
            onChange={(e) => setPendingDataPoint(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === "Enter") {
                e.preventDefault();
                addPendingDataPoint();
              } else if (e.key === "," || e.key === " ") {
                e.preventDefault();
                addPendingDataPoint();
              }
            }}
            className="rounded-r-none"
            {...props}
            ref={ref}
          />
          <Button
            type="button"
            variant="secondary"
            className="rounded-l-none border border-l-0"
            onClick={addPendingDataPoint}
          >
            Add
          </Button>
        </div>
        {value.length > 0 && (
          <div className=" rounded-md min-h-[2.5rem] overflow-y-auto py-2 flex gap-2 flex-wrap items-center">
            {value.map((item, idx) => (
              <Badge key={idx} variant="secondary">
                {item}
                <button
                  type="button"
                  className="w-3 ml-2"
                  onClick={() => {
                    onChange(value.filter((i) => i !== item));
                  }}
                >
                  <Cross2Icon className="w-3" />
                </button>
              </Badge>
            ))}
          </div>
        )}
      </>
    );
  }
);

Thank you @enesien for the component.

@algsupport
Copy link

@aryanbhat One note regarding your change. This will have the effect of changing the form height when the field appears and in many cases when you have other things under the form, this may not be deisrable.
I would make the function optional with a default value in order to preserve backward compatibility.
Just a suggestion.

@destpat
Copy link

destpat commented Aug 29, 2024

Work great thank you 👍 💯

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment