[feat]: Input with tags
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:
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):
Current behaviors
- Add a new tag on "," or "Enter"
- Click X to delete a tag
- When pasting in a comma-separated string, it's automatically turned into tags
- Can tab between tags and input
- Can hit backspace to delete a tag
- 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
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 what is @steepleinc/shared ?
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.
why wasn't this merged?
Tagged inputs is a great feature. It should have been merged.
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.
@shadcn why don't we help @donfour merge this feature?
bump, this would be great
++
My solution has been to render some badges above a textarea.
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
Great, Tags Input is a good solution. But maybe you need my Select Tag Input more.
UI Result:
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 };
{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.
This is very much necessary component. Any plans to merge it?
bump
"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
Any ideas?
+1 would really like to see this land
+1
+1 same here 🥲
+1
+1
+1
+1
I was also searching for "Tags" or "Chips" input at https://ui.shadcn.com/docs/components/.
+1
+1
+1
looks like shadcn dev DGAF about this :/
+1
Yay!
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