Tags Input
Editeur d'etiquettes convivial au clavier pour ajouter et supprimer des valeurs de chaine. Installez Tags Input depuis le registre VLLNT UI avec la CLI shadcn.
Editeur d'etiquettes convivial au clavier pour ajouter et supprimer des valeurs de chaine. Fait partie de la famille form dans VLLNT UI, il est publie comme entree de registre lisible par machine — copiez la source directement dans votre application avec la CLI shadcn et possedez-la, sans dependance d'execution a une bibliotheque de composants.
Apercu
Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.
Installation
Ajoutez Tags Input a votre projet avec la CLI shadcn. La source arrive dans votre code, prete a etre adaptee :
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/tags-input.jsonSource
"use client";
import * as React from "react";
import { X } from "lucide-react";
import { cn } from "../../lib/utils";
function normalizeTag(tag: string) {
return tag.trim();
}
function getNormalizedTags(tags: string[]) {
return tags.reduce<{ seen: Set<string>; tags: string[] }>(
(state, tag) => {
const normalizedTag = normalizeTag(tag);
if (normalizedTag.length > 0 && !state.seen.has(normalizedTag)) {
state.seen.add(normalizedTag);
state.tags.push(normalizedTag);
}
return state;
},
{ seen: new Set(), tags: [] },
).tags;
}
function shouldAddTagFromKey(key: string) {
return key === "Enter" || key === ",";
}
type TagsInputStateOptions = {
defaultValue: string[];
onValueChange?: (value: string[]) => void;
value?: string[];
};
type TagsInputHandlersOptions = {
disabled: boolean;
inputValue: string;
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
setInputValue: React.Dispatch<React.SetStateAction<string>>;
tags: string[];
updateTags: (nextTags: string[]) => void;
};
type TagListProps = {
disabled: boolean;
onRemove: (tag: string) => void;
tags: string[];
};
function useTagsInputState({
defaultValue,
onValueChange,
value,
}: TagsInputStateOptions) {
const [uncontrolledValue, setUncontrolledValue] = React.useState(() =>
getNormalizedTags(defaultValue),
);
const isControlled = value !== undefined;
const tags = React.useMemo(
() => getNormalizedTags(value ?? uncontrolledValue),
[uncontrolledValue, value],
);
const updateTags = React.useCallback(
(nextTags: string[]) => {
const normalizedTags = getNormalizedTags(nextTags);
if (!isControlled) {
setUncontrolledValue(normalizedTags);
}
onValueChange?.(normalizedTags);
},
[isControlled, onValueChange],
);
return { tags, updateTags };
}
function useTagsInputHandlers({
disabled,
inputValue,
onKeyDown,
setInputValue,
tags,
updateTags,
}: TagsInputHandlersOptions) {
const removeTag = React.useCallback(
(tagToRemove: string) => {
updateTags(tags.filter((tag) => tag !== tagToRemove));
},
[tags, updateTags],
);
const commitTag = React.useCallback(() => {
const nextTag = normalizeTag(inputValue);
if (nextTag.length === 0 || tags.includes(nextTag)) {
setInputValue("");
return;
}
updateTags([...tags, nextTag]);
setInputValue("");
}, [inputValue, setInputValue, tags, updateTags]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented || disabled) {
return;
}
if (shouldAddTagFromKey(event.key)) {
event.preventDefault();
commitTag();
return;
}
if (
(event.key === "Backspace" || event.key === "Delete") &&
inputValue.length === 0
) {
const lastTag = tags.at(-1);
if (lastTag) {
event.preventDefault();
removeTag(lastTag);
}
}
},
[commitTag, disabled, inputValue.length, onKeyDown, removeTag, tags],
);
return { handleKeyDown, removeTag };
}
function TagList({ disabled, onRemove, tags }: TagListProps) {
return (
<ul className="flex flex-wrap items-center gap-2">
{tags.map((tag) => (
<li
className="flex items-center gap-1 rounded-md border bg-muted px-2 py-1 text-sm text-foreground"
key={tag}
>
<span>{tag}</span>
<button
aria-label={`Remove ${tag}`}
className="rounded-sm text-muted-foreground outline-none ring-offset-background transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
disabled={disabled}
onClick={(event) => {
event.stopPropagation();
onRemove(tag);
}}
type="button"
>
<X className="size-3.5" />
</button>
</li>
))}
</ul>
);
}
export type TagsInputProps = Omit<
React.ComponentPropsWithoutRef<"input">,
"defaultValue" | "onChange" | "value"
> & {
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
value?: string[];
};
const TagsInput = ({
className,
defaultValue = [],
disabled = false,
onBlur,
onKeyDown,
onValueChange,
placeholder = "Add a tag",
ref,
value,
...props
}: TagsInputProps & { ref?: React.Ref<HTMLInputElement> }) => {
const [inputValue, setInputValue] = React.useState("");
const { tags, updateTags } = useTagsInputState({
defaultValue,
onValueChange,
value,
});
const { handleKeyDown, removeTag } = useTagsInputHandlers({
disabled,
inputValue,
onKeyDown,
setInputValue,
tags,
updateTags,
});
return (
<div
aria-disabled={disabled || undefined}
className={cn(
"flex min-h-10 w-full flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background transition-colors focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
disabled && "cursor-not-allowed opacity-50",
className,
)}
data-disabled={disabled ? "true" : undefined}
role="group"
>
<TagList disabled={disabled} onRemove={removeTag} tags={tags} />
<input
{...props}
className="min-w-[8rem] flex-1 border-0 bg-transparent p-0 text-sm placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed"
disabled={disabled}
onBlur={onBlur}
onChange={(event) => {
setInputValue(event.target.value);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
ref={ref}
type="text"
value={inputValue}
/>
</div>
);
};
TagsInput.displayName = "TagsInput";
export { TagsInput };
Stories
Explorez chaque variante et etat dans le Storybook interactif :
Apercu
Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.