buzzform
uitagssource: ok · hit
TagsField
Tag input field for BuzzForm v2.
npx shadcn add "https://form.buildnbuzz.com/r/tags.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightRegistry dependencies
@buzzform/initfield@buzzform/copy
Files2 · source cached 10/8/2026, 9:05:52 PM
registry/shadcn/fields/tags.tsx
"use client";
import * as React from "react";
import type { TagsField as TagsFieldDef } from "@buildnbuzz/form-react";
import { useDataField } from "@buildnbuzz/form-react";
import { CopyButton } from "../components/copy-button";
import {
TagInput,
TagInputList,
TagInputItem,
TagInputControl,
type TagInputDelimiter,
} from "../components/tag-input";
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// UI options
// ---------------------------------------------------------------------------
interface TagsUi {
/** Keys that create a new tag. Defaults to `["enter"]`. */
delimiters?: ("enter" | "comma" | "space" | "tab")[];
/** Visual variant for tag chips. Defaults to `"chips"`. */
variant?: "chips" | "pills" | "inline";
/** Show a copy button that copies all tags as a comma-separated string. */
copyable?: boolean;
/** className applied to `<FieldGroup>`. */
className?: string;
/** Inline width applied to `<FieldGroup>`. */
width?: string | number;
/** Controls required asterisk visibility. */
asterisk?: boolean;
/** Text and label overrides. */
labels?: {
/** Custom text for tag count. Defaults to `"{count} / {max} tags"`. */
count?: React.ReactNode;
};
}
// ---------------------------------------------------------------------------
// TagsField
// ---------------------------------------------------------------------------
export function TagsField() {
const {
fieldApi,
field,
isDisabled,
isReadOnly,
isRequired,
label,
placeholder,
description,
errors,
isInvalid,
descriptionId,
errorId,
ariaDescribedBy,
handleBlur,
} = useDataField<TagsFieldDef>();
const ui = field.ui as TagsUi | undefined;
const delimiters = (ui?.delimiters ?? ["enter"]).map((d) =>
d.charAt(0).toUpperCase() + d.slice(1)
) as TagInputDelimiter[];
const variant = ui?.variant ?? "chips";
const copyable = ui?.copyable ?? false;
const width = ui?.width;
const widthStyle = width
? {
width: typeof width === "number" ? `${width}px` : width,
flex: "0 1 auto",
}
: undefined;
const value = Array.isArray(fieldApi.state.value)
? (fieldApi.state.value as string[])
: [];
return (
<FieldGroup
data-field={fieldApi.name}
className={ui?.className}
style={widthStyle}
>
<Field data-invalid={isInvalid} data-disabled={isDisabled}>
{label && (
<FieldLabel htmlFor={fieldApi.name} className="gap-1 items-baseline">
{label}
{isRequired && ui?.asterisk !== false ? (
<span className="text-destructive">*</span>
) : null}
</FieldLabel>
)}
<FieldContent>
<div className="relative">
<TagInput
value={value}
onValueChange={fieldApi.handleChange}
variant={variant}
disabled={isDisabled}
readOnly={isReadOnly}
maxTags={field.maxTags}
allowDuplicates={field.allowDuplicates}
maxTagLength={field.maxTagLength}
>
<TagInputList className={cn(copyable && "pr-10")}>
{value.map((tag, i) => (
<TagInputItem key={`${tag}-${i}`} index={i} />
))}
<TagInputControl
id={fieldApi.name}
delimiters={delimiters}
placeholder={placeholder || "Add tags..."}
onBlur={handleBlur}
autoComplete={field.autoComplete}
aria-invalid={isInvalid}
aria-describedby={ariaDescribedBy}
/>
</TagInputList>
</TagInput>
{copyable && value.length > 0 && (
<CopyButton
value={value.join(", ")}
disabled={isDisabled}
className="absolute right-0 top-0 h-full px-2.5 text-muted-foreground hover:text-foreground"
/>
)}
</div>
{field.maxTags !== undefined && (
<p className="mt-1 text-xs text-muted-foreground">
{ui?.labels?.count ?? `${value.length} / ${field.maxTags} tags`}
</p>
)}
</FieldContent>
{description && !isInvalid && (
<FieldDescription id={descriptionId}>{description}</FieldDescription>
)}
{isInvalid && <FieldError id={errorId} errors={errors} />}
</Field>
</FieldGroup>
);
}
registry/shadcn/components/tag-input.tsx
"use client";
import * as React from "react";
import { cva } from "class-variance-authority";
import { IconPlaceholder } from "@/components/icon-placeholder";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Variants (CVA)
// ---------------------------------------------------------------------------
const tagInputVariants = cva(
"flex min-h-8 w-full flex-wrap items-center gap-1.5 rounded-lg border border-input bg-transparent bg-clip-padding px-3 py-1.5 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50",
{
variants: {
disabled: {
true: "cursor-not-allowed opacity-50",
},
readOnly: {
true: "cursor-default bg-muted/30",
},
},
}
);
const tagItemVariants = cva(
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 font-medium whitespace-nowrap text-foreground transition-all",
{
variants: {
variant: {
chips: "rounded-sm bg-muted px-1.5 text-xs",
pills: "rounded-full bg-muted px-3 text-xs",
inline: "rounded-none border-0 border-b-2 border-muted-foreground/30 bg-transparent px-1 text-sm",
},
},
defaultVariants: {
variant: "chips",
},
}
);
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
type TagInputContextType = {
value: string[];
maxTags?: number;
allowDuplicates?: boolean;
maxTagLength?: number;
onValueChange?: (value: string[]) => void;
disabled?: boolean;
readOnly?: boolean;
variant: "chips" | "pills" | "inline";
addTag: (tag: string) => boolean;
removeTag: (index: number) => void;
inputValue: string;
setInputValue: (val: string) => void;
inputRef: React.RefObject<HTMLInputElement | null>;
};
const TagInputContext = React.createContext<TagInputContextType | null>(null);
function useTagInputContext() {
const context = React.useContext(TagInputContext);
if (!context) {
throw new Error("TagInput components must be used within a <TagInput />");
}
return context;
}
// ---------------------------------------------------------------------------
// TagInput Root
// ---------------------------------------------------------------------------
export type TagInputDelimiter = "Enter" | "Comma" | "Space" | "Tab" | string;
export interface TagInputProps {
value?: string[];
onValueChange?: (value: string[]) => void;
disabled?: boolean;
readOnly?: boolean;
variant?: "chips" | "pills" | "inline";
maxTags?: number;
allowDuplicates?: boolean;
maxTagLength?: number;
children?: React.ReactNode;
}
export const TagInput = ({
value = [],
onValueChange,
disabled,
readOnly,
variant = "chips",
maxTags,
allowDuplicates = false,
maxTagLength,
children,
}: TagInputProps) => {
const [inputValue, setInputValue] = React.useState("");
const inputRef = React.useRef<HTMLInputElement>(null);
const addTag = (tag: string): boolean => {
const trimmed = tag.trim();
if (!trimmed) return false;
if (maxTagLength && trimmed.length > maxTagLength) return false;
if (!allowDuplicates && value.includes(trimmed)) return false;
if (maxTags !== undefined && value.length >= maxTags) return false;
onValueChange?.([...value, trimmed]);
return true;
};
const removeTag = (index: number) => {
if (disabled || readOnly) return;
onValueChange?.(value.filter((_, i) => i !== index));
};
return (
<TagInputContext.Provider
value={{
value,
maxTags,
allowDuplicates,
maxTagLength,
onValueChange,
disabled,
readOnly,
variant,
addTag,
removeTag,
inputValue,
setInputValue,
inputRef,
}}
>
{children}
</TagInputContext.Provider>
);
};
// ---------------------------------------------------------------------------
// TagInputList
// ---------------------------------------------------------------------------
export type TagInputListProps = React.ComponentPropsWithoutRef<"div">;
export const TagInputList = React.forwardRef<HTMLDivElement, TagInputListProps>(
({ className, ...props }, ref) => {
const { disabled, readOnly, inputRef } = useTagInputContext();
return (
<div
ref={ref}
onClick={() => !disabled && !readOnly && inputRef.current?.focus()}
className={cn(tagInputVariants({ disabled, readOnly, className }))}
{...props}
/>
);
}
);
TagInputList.displayName = "TagInputList";
// ---------------------------------------------------------------------------
// TagInputItem
// ---------------------------------------------------------------------------
export interface TagInputItemProps extends React.HTMLAttributes<HTMLDivElement> {
index: number;
}
export const TagInputItem = React.forwardRef<HTMLDivElement, TagInputItemProps>(
({ className, index, children, ...props }, ref) => {
const { variant, value, disabled, readOnly, removeTag } = useTagInputContext();
const tag = value[index];
return (
<div
ref={ref}
className={cn(tagItemVariants({ variant, className }))}
{...props}
>
{children ?? <span className="truncate max-w-40">{tag}</span>}
{!disabled && !readOnly && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeTag(index);
}}
className="ml-0.5 rounded-full p-0.5 opacity-50 hover:bg-muted-foreground/20 hover:opacity-100 focus:outline-none focus:ring-1 focus:ring-ring"
aria-label={`Remove ${tag}`}
>
<IconPlaceholder
lucide="X"
hugeicons="Cancel01Icon"
tabler="IconX"
phosphor="X"
remixicon="RiCloseLine"
className="size-3"
/>
</button>
)}
</div>
);
}
);
TagInputItem.displayName = "TagInputItem";
// ---------------------------------------------------------------------------
// TagInputControl
// ---------------------------------------------------------------------------
export interface TagInputControlProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "onChange"> {
delimiters?: TagInputDelimiter[];
}
export const TagInputControl = React.forwardRef<HTMLInputElement, TagInputControlProps>(
({ className, delimiters = ["Enter"], placeholder, onBlur, onKeyDown, ...props }, ref) => {
const {
inputValue,
setInputValue,
addTag,
removeTag,
value: tags,
maxTags,
disabled,
readOnly,
inputRef,
} = useTagInputContext();
// Sync external forward ref with context ref
React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Backspace" && !inputValue && tags.length > 0) {
removeTag(tags.length - 1);
return;
}
const isDelimiter = delimiters.some((d) => {
if (d === "Comma" && e.key === ",") return true;
if (d === "Space" && e.key === " ") return true;
return d.toLowerCase() === e.key.toLowerCase();
});
if (isDelimiter && inputValue.trim()) {
e.preventDefault();
if (addTag(inputValue)) {
setInputValue("");
}
}
onKeyDown?.(e);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
const hasCommaDelimiter = delimiters.some((d) => d === "Comma" || d === ",");
if (hasCommaDelimiter && val.includes(",")) {
const parts = val.split(",");
const last = parts.pop() ?? "";
parts.forEach((p) => {
addTag(p);
});
setInputValue(last);
return;
}
setInputValue(val);
};
const handleInputBlur = (e: React.FocusEvent<HTMLInputElement>) => {
if (inputValue.trim()) {
if (addTag(inputValue)) {
setInputValue("");
}
}
onBlur?.(e);
};
if (readOnly) return null;
const canAddMore = maxTags === undefined || tags.length < maxTags;
if (!canAddMore) return null;
return (
<input
{...props}
ref={inputRef}
type="text"
value={inputValue}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleInputBlur}
disabled={disabled}
placeholder={tags.length === 0 ? placeholder : ""}
className={cn(
"flex-1 min-w-20 bg-transparent outline-none text-sm placeholder:text-muted-foreground disabled:cursor-not-allowed",
className
)}
autoComplete={props.autoComplete ?? "off"}
/>
);
}
);
TagInputControl.displayName = "TagInputControl";