ui icon indicating copy to clipboard operation
ui copied to clipboard

[feat]: Input with tags

Open donfour opened this issue 1 year ago • 35 comments

Feature description

Motivation

In many forms, it's a common use case for a user to have to input an array of strings for a field (#2236).

For example, when sharing a Figma file with other users, I can input multiple emails: Screen Shot 2024-05-02 at 10 52 37 AM

I think this is a neat and common user pattern that could be useful to many people.

Proposal

An input component that allows users to enter multiple values. These become tags that appear within the input. Users can delete these tags afterwards.

Implementation

I tried implementing the component myself (demo here):

Screen Shot 2024-05-02 at 11 07 41 AM

Current behaviors

  1. Add a new tag on "," or "Enter"
  2. Click X to delete a tag
  3. When pasting in a comma-separated string, it's automatically turned into tags
  4. Can tab between tags and input
  5. Can hit backspace to delete a tag
  6. Duplicate values are ignored

Component implementation

// input-tags.tsx

"use client";

import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react";
import { cn } from "@/utils/cn";
import { type InputProps } from "./input";

type InputTagsProps = Omit<InputProps, "value" | "onChange"> & {
  value: string[];
  onChange: React.Dispatch<React.SetStateAction<string[]>>;
};

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

    React.useEffect(() => {
      if (pendingDataPoint.includes(",")) {
        const newDataPoints = new Set([
          ...value,
          ...pendingDataPoint.split(",").map((chunk) => chunk.trim()),
        ]);
        onChange(Array.from(newDataPoints));
        setPendingDataPoint("");
      }
    }, [pendingDataPoint, onChange, value]);

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

    return (
      <div
        className={cn(
          // caveat: :has() variant requires tailwind v3.4 or above: https://tailwindcss.com/blog/tailwindcss-v3-4#new-has-variant
          "has-[:focus-visible]:outline-none has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-neutral-950 has-[:focus-visible]:ring-offset-2 dark:has-[:focus-visible]:ring-neutral-300 min-h-10 flex w-full flex-wrap gap-2 rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm ring-offset-white  disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-800 dark:bg-neutral-950 dark:ring-offset-neutral-950",
          className
        )}
      >
        {value.map((item) => (
          <Badge key={item} variant="secondary">
            {item}
            <Button
              variant="ghost"
              size="icon"
              className="ml-2 h-3 w-3"
              onClick={() => {
                onChange(value.filter((i) => i !== item));
              }}
            >
              <XIcon className="w-3" />
            </Button>
          </Badge>
        ))}
        <input
          className="flex-1 outline-none placeholder:text-neutral-500 dark:placeholder:text-neutral-400"
          value={pendingDataPoint}
          onChange={(e) => setPendingDataPoint(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === "Enter" || e.key === ",") {
              e.preventDefault();
              addPendingDataPoint();
            } else if (
              e.key === "Backspace" &&
              pendingDataPoint.length === 0 &&
              value.length > 0
            ) {
              e.preventDefault();
              onChange(value.slice(0, -1));
            }
          }}
          {...props}
          ref={ref}
        />
      </div>
    );
  }
);

InputTags.displayName = "InputTags";

export { InputTags };

Component usage

"use client";

import { useState } from "react";
import { InputTags } from "@/components/ui/input-tags";

export default function Page() {
  const [values, setValues] = useState<string[]>([]);
  return (
        <InputTags
          value={values}
          onChange={setValues}
          placeholder="Enter values, comma separated..."
          className="max-w-[500px]"
        />
  );
}

Inspirations

  • https://gist.github.com/enesien/03ba5340f628c6c812b306da5fedd1a4
  • Figma's share file UI design

Let me know if this is useful! I'm more than happy to contribute a PR 😄

Affected component/components

Input, Badge

Additional Context

Additional details here...

Before submitting

  • [X] I've made research efforts and searched the documentation
  • [X] I've searched for existing issues and PRs

donfour avatar May 02 '24 18:05 donfour

I liked this a lot. Added a validator to it that can optionally be passed in:

'use client'

import { forwardRef, useEffect, useState } from 'react'
import { Option, pipe, ReadonlyArray, String } from 'effect'
import { XIcon } from 'lucide-react'
import type { z } from 'zod'

import { noOp } from '@steepleinc/shared'

import { Badge } from '~/components/ui/badge'
import { Button } from '~/components/ui/button'
import { cn } from '~/shared/utils'
import type { InputProps } from './input'

const parseTagOpt = (params: { tag: string; tagValidator: z.ZodString }) => {
  const { tag, tagValidator } = params

  const parsedTag = tagValidator.safeParse(tag)

  if (parsedTag.success) {
    return pipe(parsedTag.data, Option.some)
  }

  return Option.none()
}

type TagInputProps = Omit<InputProps, 'value' | 'onChange'> & {
  value?: ReadonlyArray<string>
  onChange: (value: ReadonlyArray<string>) => void
  tagValidator?: z.ZodString
}

const TagInput = forwardRef<HTMLInputElement, TagInputProps>((props, ref) => {
  const { className, value = [], onChange, tagValidator, ...domProps } = props

  const [pendingDataPoint, setPendingDataPoint] = useState('')

  useEffect(() => {
    if (pendingDataPoint.includes(',')) {
      const newDataPoints = new Set([
        ...value,
        ...pipe(
          pendingDataPoint,
          String.split(','),
          ReadonlyArray.filterMap((x) => {
            const trimmedX = pipe(x, String.trim)

            return pipe(
              tagValidator,
              Option.fromNullable,
              Option.match({
                onNone: () => pipe(trimmedX, Option.some),
                onSome: (y) => parseTagOpt({ tag: trimmedX, tagValidator: y }),
              }),
            )
          }),
        ),
      ])
      onChange(Array.from(newDataPoints))
      setPendingDataPoint('')
    }
  }, [pendingDataPoint, onChange, value, tagValidator])

  const addPendingDataPoint = () => {
    if (pendingDataPoint) {
      pipe(
        tagValidator,
        Option.fromNullable,
        Option.match({
          onNone: () => {
            const newDataPoints = new Set([...value, pendingDataPoint])
            onChange(Array.from(newDataPoints))
            setPendingDataPoint('')
          },
          onSome: (y) =>
            pipe(
              parseTagOpt({ tag: pendingDataPoint, tagValidator: y }),
              Option.match({
                onNone: noOp,
                onSome: (x) => {
                  const newDataPoints = new Set([...value, x])
                  onChange(Array.from(newDataPoints))
                  setPendingDataPoint('')
                },
              }),
            ),
        }),
      )
    }
  }

  return (
    <div
      className={cn(
        // caveat: :has() variant requires tailwind v3.4 or above: https://tailwindcss.com/blog/tailwindcss-v3-4#new-has-variant
        'has-[:focus-visible]:ring-neutral-950 dark:has-[:focus-visible]:ring-neutral-300 border-neutral-200 dark:border-neutral-800 dark:bg-neutral-950 dark:ring-offset-neutral-950 flex min-h-10 w-full flex-wrap gap-2 rounded-md border bg-white px-3 py-2 text-sm ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 has-[:focus-visible]:outline-none has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-offset-2',
        className,
      )}
    >
      {value.map((item) => (
        <Badge key={item} variant={'secondary'}>
          {item}
          <Button
            variant={'ghost'}
            size={'icon'}
            className={'ml-2 h-3 w-3'}
            onClick={() => {
              onChange(value.filter((i) => i !== item))
            }}
          >
            <XIcon className={'w-3'} />
          </Button>
        </Badge>
      ))}
      <input
        className={
          'placeholder:text-neutral-500 dark:placeholder:text-neutral-400 flex-1 outline-none'
        }
        value={pendingDataPoint}
        onChange={(e) => setPendingDataPoint(e.target.value)}
        onKeyDown={(e) => {
          if (e.key === 'Enter' || e.key === ',') {
            e.preventDefault()
            addPendingDataPoint()
          } else if (
            e.key === 'Backspace' &&
            pendingDataPoint.length === 0 &&
            value.length > 0
          ) {
            e.preventDefault()
            onChange(value.slice(0, -1))
          }
        }}
        {...domProps}
        ref={ref}
      />
    </div>
  )
})

TagInput.displayName = 'TagInput'

export { TagInput }

izakfilmalter avatar May 07 '24 10:05 izakfilmalter

@izakfilmalter what is @steepleinc/shared ?

alejoar avatar Jun 06 '24 10:06 alejoar

This issue has been automatically closed because it received no activity for a while. If you think it was closed by accident, please leave a comment. Thank you.

shadcn avatar Jun 29 '24 23:06 shadcn

why wasn't this merged?

KorigamiK avatar Jul 02 '24 23:07 KorigamiK

Tagged inputs is a great feature. It should have been merged.

mohdabidansari avatar Jul 03 '24 07:07 mohdabidansari

Hey @shadcn ! What do you think about potentially adding this as a component? I know you're probably busy with the recent charts release, so I'm happy to take on the bulk of the work to put together a PR.

donfour avatar Jul 09 '24 22:07 donfour

@shadcn why don't we help @donfour merge this feature?

kevinnammour avatar Aug 20 '24 23:08 kevinnammour

bump, this would be great

alvinometric avatar Aug 28 '24 14:08 alvinometric

++

davidkhierl avatar Aug 31 '24 08:08 davidkhierl

My solution has been to render some badges above a textarea.

image

Demo: https://lucide-studio.vercel.app/edit?dialog=true&name=&base=ambulance%250Aanvil Source: https://github.com/jguddas/lucide-studio/blob/main/src/components/ContributionDialog/TagInput.tsx

jguddas avatar Aug 31 '24 09:08 jguddas

Great, Tags Input is a good solution. But maybe you need my Select Tag Input more.

UI Result:

image

How to use with RTF:

const [tags, setTags] = useState<string[]>([]);
<FormField
      control={form.control}
      name="approvableUserIds"
      render={({ field }) => (
        <FormItem>
          <FormLabel>Approvable Users</FormLabel>
          <FormControl>
            <SelectTagInput
              {...field}
              value={tags}
              onChange={setTags}
              options={[
                { label: 'JavaScript', value: 'js' },
                { label: 'TypeScript', value: 'ts' },
                { label: 'React', value: 'react' },
                { label: 'Node.js', value: 'node' },
                { label: 'GraphQL', value: 'graphql' },
              ]}
            />
          </FormControl>
          <FormMessage />
        </FormItem>
      )}
    />

Component Code:

'use client';

import * as React from 'react';
import { XIcon } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { type InputProps } from './input';
import { cn } from '@/lib/utils';

type Option = {
  label: string;
  value: string;
};

type SelectTagInputProps = Omit<InputProps, 'value' | 'onChange'> & {
  value: string[];
  onChange: React.Dispatch<React.SetStateAction<string[]>>;
  options: Option[];
};

const SelectTagInput = React.forwardRef<HTMLInputElement, SelectTagInputProps>(
  ({ className, value, onChange, options, ...props }, ref) => {
    const [pendingDataPoint, setPendingDataPoint] = React.useState('');
    const [isDropdownOpen, setDropdownOpen] = React.useState(false);

    const addPendingDataPoint = (newOption?: Option) => {
      if (newOption) {
        if (!value.includes(newOption.value)) {
          onChange([...value, newOption.value]);
        }
      } else if (pendingDataPoint) {
        const matchedOption = options.find(
          (option) => option.label.toLowerCase() === pendingDataPoint.trim().toLowerCase(),
        );
        if (matchedOption && !value.includes(matchedOption.value)) {
          onChange([...value, matchedOption.value]);
        }
      }
      setPendingDataPoint('');
      setDropdownOpen(false);
    };

    const getLabelByValue = (val: string) => {
      const matchedOption = options.find((option) => option.value === val);

      return matchedOption ? matchedOption.label : val;
    };

    return (
      <div className={cn('relative', className)}>
        <div
          className={cn(
            'has-[:focus-visible]:outline-none has-[:focus-visible]:ring-1 has-[:focus-visible]:ring-neutral-950 has-[:focus-visible]:ring-offset-0 dark:has-[:focus-visible]:ring-neutral-300 min-h-10 flex w-full flex-wrap gap-2 rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm ring-offset-white  disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-800 dark:bg-neutral-950 dark:ring-offset-neutral-950',
          )}
        >
          {value.map((val) => (
            <Badge key={val} variant="secondary">
              {getLabelByValue(val)}
              <Button
                variant="ghost"
                size="icon"
                className="ml-2 h-3 w-3"
                onClick={() => onChange(value.filter((i) => i !== val))}
              >
                <XIcon className="w-3" />
              </Button>
            </Badge>
          ))}
          <input
            className={cn('flex-1 outline-none placeholder:text-neutral-500 dark:placeholder:text-neutral-400')}
            value={pendingDataPoint}
            onChange={(e) => {
              setPendingDataPoint(e.target.value);
              setDropdownOpen(true);
            }}
            onKeyDown={(e) => {
              if (e.key === 'Enter' || e.key === ',') {
                e.preventDefault();
                addPendingDataPoint();
              } else if (e.key === 'Backspace' && pendingDataPoint.length === 0 && value.length > 0) {
                e.preventDefault();
                onChange(value.slice(0, -1));
              }
            }}
            onBlur={() => setDropdownOpen(false)}
            {...props}
            ref={ref}
          />
        </div>
        {isDropdownOpen && pendingDataPoint && (
          <ul
            className="absolute left-0 mt-1 max-h-60 w-full overflow-auto rounded-md border border-neutral-200 bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm dark:border-neutral-800 dark:bg-neutral-950"
            role="listbox"
          >
            {options.filter(
              (option) =>
                option.label.toLowerCase().includes(pendingDataPoint.toLowerCase()) && !value.includes(option.value),
            ).length > 0 ? (
              options
                .filter(
                  (option) =>
                    option.label.toLowerCase().includes(pendingDataPoint.toLowerCase()) &&
                    !value.includes(option.value),
                )
                .map((option) => (
                  <li
                    key={option.value}
                    className="cursor-pointer select-none px-4 py-2 text-neutral-900 hover:bg-neutral-100 dark:text-neutral-200 dark:hover:bg-neutral-800"
                    onClick={() => addPendingDataPoint(option)}
                  >
                    {option.label}
                  </li>
                ))
            ) : (
              <li className="cursor-not-allowed select-none px-4 py-2 text-neutral-500 dark:text-neutral-400">
                No options found
              </li>
            )}
          </ul>
        )}
      </div>
    );
  },
);

SelectTagInput.displayName = 'SelectTagInput';

export { SelectTagInput };

Juu-dev avatar Sep 04 '24 02:09 Juu-dev

{value.map((item) => (
  <Badge key={item} variant="secondary">
    {item}
    <Button
      type="button"
      variant="ghost"
      size="icon"
      className="ml-2 h-3 w-3"
      onClick={() => {
        console.log("Came here");
        onChange(value.filter((i) => i !== item));
      }}
      tabIndex="-1"
    >
      <XIcon className="w-3" />
    </Button>
  </Badge>
))}

Add type="button" to the removal buttons. Otherwise they are considered as submits of the form and every enter in other input fields triggers them.

MatthiasGruba avatar Oct 21 '24 07:10 MatthiasGruba

This is very much necessary component. Any plans to merge it?

bolshchikov avatar Oct 24 '24 12:10 bolshchikov

bump

lukasz-gil-scalis avatar Oct 29 '24 10:10 lukasz-gil-scalis


"use client";

import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";

type InputTagsProps = Omit<
  React.InputHTMLAttributes<HTMLInputElement>,
  "value" | "onChange"
> & {
  value: string[];
  onChange: React.Dispatch<React.SetStateAction<string[]>>;
};

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

    React.useEffect(() => {
      if (pendingDataPoint.includes(",")) {
        const newDataPoints = new Set([
          ...value,
          ...pendingDataPoint.split(",").map((chunk) => chunk.trim()),
        ]);
        onChange(Array.from(newDataPoints));
        setPendingDataPoint("");
      }
    }, [pendingDataPoint, onChange, value]);

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

    return (
      <div
        className={cn(
          // caveat: :has() variant requires tailwind v3.4 or above: https://tailwindcss.com/blog/tailwindcss-v3-4#new-has-variant
          "has-[:focus-visible]:outline-none has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-neutral-950 has-[:focus-visible]:ring-offset-2 dark:has-[:focus-visible]:ring-neutral-300 min-h-10 flex w-full flex-wrap gap-2 rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm ring-offset-white  disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-800 dark:bg-neutral-950 dark:ring-offset-neutral-950",
          className,
        )}
      >
        {value.map((item) => (
          <Badge key={item} variant="secondary">
            {item}
            <Button
              variant="ghost"
              size="icon"
              className="ml-2 h-3 w-3"
              onClick={() => {
                onChange(value.filter((i) => i !== item));
              }}
            >
              <XIcon className="w-3" />
            </Button>
          </Badge>
        ))}
        <input
          className="flex-1 outline-none placeholder:text-neutral-500 dark:placeholder:text-neutral-400"
          value={pendingDataPoint}
          onChange={(e) => setPendingDataPoint(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === "Enter" || e.key === ",") {
              e.preventDefault();
              addPendingDataPoint();
            } else if (
              e.key === "Backspace" &&
              pendingDataPoint.length === 0 &&
              value.length > 0
            ) {
              e.preventDefault();
              onChange(value.slice(0, -1));
            }
          }}
          {...props}
          ref={ref}
        />
      </div>
    );
  },
);

InputTags.displayName = "InputTags";

export { InputTags };

Using this exact code my tags are created outside the input field and the input itself is pushed to the right Screenshot 2024-10-31 at 19 27 47

Any ideas?

Katrolli avatar Oct 31 '24 18:10 Katrolli

+1 would really like to see this land

nathangroblewski avatar Nov 10 '24 04:11 nathangroblewski

+1

nkrowicki avatar Nov 27 '24 04:11 nkrowicki

+1 same here 🥲

marisaroque avatar Nov 27 '24 10:11 marisaroque

+1

Comi9 avatar Nov 28 '24 20:11 Comi9

+1

jhigger avatar Dec 01 '24 16:12 jhigger

+1

alessioacella avatar Dec 27 '24 18:12 alessioacella

+1

nazrdogan avatar Jan 05 '25 21:01 nazrdogan

I was also searching for "Tags" or "Chips" input at https://ui.shadcn.com/docs/components/.

dandv avatar Jan 15 '25 07:01 dandv

+1

mackenzienolan avatar Jan 16 '25 23:01 mackenzienolan

+1

amine-tayani avatar Jan 21 '25 10:01 amine-tayani

+1

AnikG avatar Jan 23 '25 19:01 AnikG

looks like shadcn dev DGAF about this :/

rushkii avatar Jan 31 '25 09:01 rushkii

+1

pietrodichio avatar Feb 05 '25 16:02 pietrodichio

Yay!

bolshchikov avatar Feb 05 '25 16:02 bolshchikov

I encountered the problem of tags, I don't know if it can help, but I put the package in open source. If you want me to push the project further, put a GitHub star and make me issues or proposals. thx

package shadecnui tags input and doc

Cornelius-BobCat avatar Feb 11 '25 14:02 Cornelius-BobCat