/**
* TagInput — a field whose value is a list of tokens rather than a string.
*
* The value is whatever gets typed. That is the whole distinction from a
* `Combobox` in `multiple` mode: a Combobox picks from a set of options you
* supply, so it needs a list, a filter and a surface to float that list on. A
* tag field has no options and no list — labels on a task, recipients on a
* message, keywords on a post — so it carries none of that machinery and never
* opens a portal.
*
* ```tsx
*
* ```
*
* ## Three ways a tag gets committed
*
* Return commits what has been typed. So does any of `delimiters` — a comma by
* default — which is what makes pasting `design, research, ops` land as three
* tags instead of one long one. And `blurBehavior` decides what a field that
* loses focus mid-word does with the leftover.
*
* ## Backspace asks first
*
* Backspace on an empty field marks the last tag rather than taking it: the
* tag turns destructive, and a second backspace removes it. A held backspace
* repeats, and a field that deleted on the first one would empty itself in the
* time it takes to notice — the mark is the beat that lets you stop.
*/
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import {
Pressable,
TextInput,
View,
type NativeSyntheticEvent,
type TextInputKeyPressEventData,
type TextInputProps,
} from 'react-native';
import Animated, {
FadeIn,
FadeOut,
LinearTransition,
interpolateColor,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { tv, type VariantProps } from 'tailwind-variants';
import { useCSSVariable } from 'uniwind';
import { XIcon } from '../../icons';
import { Text } from '../../primitives/text';
import { selectionTick } from '../../utils/haptics';
import { Chip, type ChipSize, type ChipVariant } from '../chip';
import { Label } from '../label';
import { reconcileMarkedTag, type MarkedTag } from './tag-input-state';
/** Matches Input and Combobox, so the three read as the same control focused. */
const FOCUS_DURATION = 150;
/** Long enough to see a tag arrive or leave, short enough not to queue up. */
const ENTER_DURATION = 140;
const EXIT_DURATION = 120;
const REFLOW_DURATION = 180;
const tagInputVariants = tv({
slots: {
container: 'w-full gap-1.5',
/*
* The border colour is animated between the resting and focused tokens, so
* it is deliberately absent from the class. `flex-wrap` is the load-bearing
* part: a tag field grows down, because the alternative is a row that
* scrolls sideways and hides the tags you just added.
*/
field: 'w-full flex-row flex-wrap items-center rounded-lg border',
/*
* A length rather than a `text-*` step, for the reason Input gives: a step
* sets a size and a line height together, and the extra leading lands above
* the glyphs, so in a box of fixed height the text sits below the tags it
* is supposed to be level with.
*/
input: 'min-w-24 flex-1 py-0 font-normal text-foreground',
clear: 'items-center justify-center rounded-full',
description: 'text-sm text-muted-foreground',
error: 'text-sm text-destructive',
count: 'text-xs text-muted-foreground',
},
variants: {
variant: {
outline: { field: 'bg-background' },
filled: { field: 'bg-muted' },
},
size: {
sm: {
field: 'gap-1 px-2.5 py-1.5',
input: 'h-7 text-[14px]',
clear: 'h-5 w-5',
},
md: {
field: 'gap-1.5 px-3 py-2',
input: 'h-8 text-[16px]',
clear: 'h-6 w-6',
},
lg: {
field: 'gap-2 px-3.5 py-2.5',
input: 'h-10 text-[16px]',
clear: 'h-7 w-7',
},
},
disabled: {
true: { field: 'opacity-[0.64]' },
},
},
defaultVariants: {
variant: 'outline',
size: 'md',
},
});
type TagInputVariantProps = VariantProps;
export type TagInputVariant = NonNullable;
export type TagInputSize = NonNullable;
/** A tag sits inside the field, so it is a step smaller than the field is. */
const CHIP_SIZE: Record = {
sm: 'sm',
md: 'sm',
lg: 'md',
};
/** Why a tag was turned away, for a caller that wants to say so. */
export type TagRejection = 'duplicate' | 'max' | 'invalid';
/** What a field that loses focus mid-word does with the leftover text. */
export type TagBlurBehavior = 'add' | 'clear' | 'keep';
/**
* Splitting without a regex, so a delimiter never has to be escaped — `.` and
* `|` are ordinary characters to someone listing the separators they use.
*/
function splitOn(text: string, delimiters: string[]): string[] {
let parts = [text];
for (const delimiter of delimiters) {
if (!delimiter) continue;
parts = parts.flatMap((part) => part.split(delimiter));
}
return parts;
}
export interface TagInputProps
extends Omit<
TextInputProps,
| 'value'
| 'defaultValue'
| 'onChangeText'
| 'editable'
| 'multiline'
| 'children'
>,
Omit {
/** Classes for the field box — the bordered container the tags sit in. */
className?: string;
/** Classes for the outer column that also holds the label and the error. */
containerClassName?: string;
/** The tags, controlled. Pair it with `onValueChange`. */
value?: string[];
/** The tags to start with, when the field keeps its own value. */
defaultValue?: string[];
/** Called with the whole list whenever a tag is added or removed. */
onValueChange?: (tags: string[]) => void;
/**
* The text being typed, controlled. Only needed to drive the draft from
* outside — the tags themselves are `value`.
*/
inputValue?: string;
/** Called as the draft text changes, before it becomes a tag. */
onInputValueChange?: (text: string) => void;
/** The label above the field, and what the input is announced as. */
label?: string;
/** A line under the field, replaced by `errorMessage` when there is one. */
description?: string;
/** Error message. When set, the field renders in its invalid state. */
errorMessage?: string;
/** Marks the field required — an asterisk on the label, and the a11y state. */
isRequired?: boolean;
/** Dims the field and stops it being reached at all. */
disabled?: boolean;
/** Shows the tags but takes away the input and the ✕ on each one. */
readOnly?: boolean;
/**
* The most tags the field accepts. Past it nothing more is committed and
* `onReject` is called with `'max'`, unless `allowOverflow` is set.
*/
max?: number;
/**
* Let the list go past `max` anyway. The field reports itself invalid while
* it is over, which is the point: some forms want the count shown as wrong
* rather than the typing refused.
*/
allowOverflow?: boolean;
/** Accept a tag the list already holds. Off by default. */
allowDuplicates?: boolean;
/**
* Characters that end a tag as they are typed. A comma by default, which is
* what makes a pasted `a, b, c` land as three tags rather than one.
*/
delimiters?: string[];
/**
* What happens to text still in the field when it loses focus. `add` commits
* it and clears it if it was accepted, leaving it in place if it was not, so
* a rejected word is still there to fix. `clear` drops it. `keep` leaves it
* exactly as typed.
*/
blurBehavior?: TagBlurBehavior;
/**
* Decide whether a tag may be added, given the list it would join. Return
* `false` to turn it away — `onReject` is then called with `'invalid'`.
*/
validate?: (tag: string, tags: string[]) => boolean;
/** Called when a tag was turned away, with the reason it was. */
onReject?: (tag: string, reason: TagRejection) => void;
/** Which Chip variant the tags are drawn as. */
chipVariant?: ChipVariant;
/** Draw the tag yourself — an avatar before the label, a count after it. */
renderTag?: (tag: string, index: number) => ReactNode;
/** A ✕ at the end of the field that empties it. */
clearable?: boolean;
/** Shows `3 / 8` under the field. Needs `max`. */
showCount?: boolean;
/**
* A tick under the finger as a tag lands or leaves. Off by default — needs
* the optional `expo-haptics`, and is silent without it.
*/
haptics?: boolean;
}
export const TagInput = forwardRef(
(
{
className,
containerClassName,
value,
defaultValue,
onValueChange,
inputValue,
onInputValueChange,
label,
description,
errorMessage,
isRequired,
disabled = false,
readOnly = false,
max,
allowOverflow = false,
allowDuplicates = false,
delimiters = [','],
blurBehavior = 'add',
validate,
onReject,
chipVariant = 'default',
renderTag,
clearable = false,
showCount = false,
haptics = false,
variant,
size = 'md',
placeholder,
onFocus,
onBlur,
onKeyPress,
onSubmitEditing,
accessibilityLabel,
...props
},
ref
) => {
const inputRef = useRef(null);
useImperativeHandle(ref, () => inputRef.current as TextInput);
const [ownTags, setOwnTags] = useState(defaultValue ?? []);
const tags = value ?? ownTags;
const [ownText, setOwnText] = useState('');
const text = inputValue ?? ownText;
const [focused, setFocused] = useState(false);
/*
* Which tag a second backspace would take. An index rather than the tag
* itself, because with `allowDuplicates` two tags can read the same and
* marking "the last one" has to mean the last one.
*/
const [marked, setMarked] = useState(null);
const overflowing = max !== undefined && tags.length > max;
const invalid = !!errorMessage || overflowing;
const interactive = !disabled && !readOnly;
// A controlled list can be replaced or reordered between the two
// backspaces. Keep the mark only while the exact tag it armed still owns
// that slot, so a key press aimed at the old value cannot delete the new one.
useEffect(() => {
setMarked((current) => reconcileMarkedTag(current, tags));
}, [tags]);
const setText = useCallback(
(next: string) => {
if (inputValue === undefined) setOwnText(next);
onInputValueChange?.(next);
},
[inputValue, onInputValueChange]
);
const commit = useCallback(
(next: string[]) => {
if (value === undefined) setOwnTags(next);
onValueChange?.(next);
},
[value, onValueChange]
);
/**
* Adds every candidate that survives the rules, in order, and reports the
* ones that do not. Returns whether anything was actually taken, which is
* what tells the caller whether the draft text has been consumed.
*/
const addTags = useCallback(
(candidates: string[]) => {
const incoming = candidates
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (incoming.length === 0) return false;
const next = [...tags];
let added = false;
for (const tag of incoming) {
if (!allowDuplicates && next.includes(tag)) {
onReject?.(tag, 'duplicate');
continue;
}
if (max !== undefined && next.length >= max && !allowOverflow) {
onReject?.(tag, 'max');
continue;
}
if (validate && !validate(tag, next)) {
onReject?.(tag, 'invalid');
continue;
}
next.push(tag);
added = true;
}
if (!added) return false;
if (haptics) selectionTick();
commit(next);
return true;
},
[tags, allowDuplicates, max, allowOverflow, validate, onReject, haptics, commit]
);
const removeAt = useCallback(
(index: number) => {
if (index < 0 || index >= tags.length) return;
if (haptics) selectionTick();
commit(tags.filter((_, position) => position !== index));
},
[tags, haptics, commit]
);
const handleChangeText = useCallback(
(next: string) => {
// Any typing at all takes the mark off: the backspace that would have
// removed a tag has been overtaken by a new word.
setMarked(null);
if (delimiters.length > 0 && delimiters.some((entry) => next.includes(entry))) {
const parts = splitOn(next, delimiters);
// The last piece is what comes *after* the final delimiter — still
// being typed, so it stays in the field rather than becoming a tag.
const trailing = parts.pop() ?? '';
addTags(parts);
setText(trailing);
return;
}
setText(next);
},
[delimiters, addTags, setText]
);
const handleSubmit = useCallback<
NonNullable
>(
(event) => {
if (addTags([text])) setText('');
onSubmitEditing?.(event);
},
[addTags, text, setText, onSubmitEditing]
);
const handleKeyPress = useCallback(
(event: NativeSyntheticEvent) => {
onKeyPress?.(event);
if (event.nativeEvent.key !== 'Backspace') {
setMarked(null);
return;
}
// There is still a character in front of the cursor: backspace means
// what it always means, and the tags are none of its business.
if (text.length > 0 || tags.length === 0) return;
if (marked !== null && reconcileMarkedTag(marked, tags)) {
removeAt(marked.index);
setMarked(null);
return;
}
const index = tags.length - 1;
setMarked({ index, tag: tags[index]! });
},
[onKeyPress, text.length, tags, marked, removeAt]
);
const handleFocus = useCallback>(
(event) => {
setFocused(true);
onFocus?.(event);
},
[onFocus]
);
const handleBlur = useCallback>(
(event) => {
setFocused(false);
setMarked(null);
if (blurBehavior === 'clear') {
setText('');
} else if (blurBehavior === 'add') {
// Only cleared when it was taken. Text that was refused stays in the
// field, because a word that vanishes on blur looks like it was
// accepted and a rejected tag has to remain visible to be fixed.
if (addTags([text])) setText('');
}
onBlur?.(event);
},
[blurBehavior, setText, addTags, text, onBlur]
);
const focusField = useCallback(() => {
inputRef.current?.focus();
}, []);
const clear = useCallback(() => {
setText('');
setMarked(null);
commit([]);
inputRef.current?.focus();
}, [setText, commit]);
const slots = tagInputVariants({ variant, size, disabled });
const placeholderColor = useCSSVariable('--color-muted-foreground');
const restColor = useCSSVariable('--color-input');
const focusColor = useCSSVariable('--color-ring');
const errorColor = useCSSVariable('--color-destructive');
const mutedColor =
typeof placeholderColor === 'string' ? placeholderColor : '#737373';
/*
* One 0..1 value rather than a class per state: Uniwind can only swap a
* class wholesale, which is the snap this is here to avoid, and a shared
* value crosses between the two colours on the UI thread without a
* re-render for every frame of it.
*/
const focus = useSharedValue(0);
useEffect(() => {
focus.value = withTiming(focused ? 1 : 0, { duration: FOCUS_DURATION });
}, [focused, focus]);
const resting = typeof restColor === 'string' ? restColor : '#e5e5e5';
const active = invalid
? typeof errorColor === 'string'
? errorColor
: '#ef4444'
: typeof focusColor === 'string'
? focusColor
: '#a3a3a3';
// An invalid field is tinted at rest too — the error is a fact about the
// value, not about whether the field happens to be focused.
const idle = invalid ? active : resting;
const borderStyle = useAnimatedStyle(() => ({
borderColor: interpolateColor(focus.value, [0, 1], [idle, active]),
}));
const hasContent = tags.length > 0 || text.length > 0;
const fieldLabel = accessibilityLabel ?? label ?? placeholder;
const countLine = useMemo(() => {
if (!showCount || max === undefined) return null;
return `${tags.length} / ${max}`;
}, [showCount, max, tags.length]);
return (
{label ? (
) : null}
{tags.map((tag, index) => (
{renderTag ? (
renderTag(tag, index)
) : (
removeAt(index) : undefined}
closeLabel={`Remove ${tag}`}
haptics={haptics}
>
{tag}
)}
))}
{readOnly ? null : (
0 ? undefined : placeholder}
placeholderTextColor={mutedColor}
autoCapitalize="none"
autoCorrect={false}
autoComplete="off"
accessibilityLabel={fieldLabel}
accessibilityState={{ disabled }}
aria-required={isRequired}
aria-invalid={invalid}
className={slots.input()}
{...props}
/>
)}
{clearable && hasContent && interactive ? (
) : null}
{errorMessage ? (
{errorMessage}
) : description ? (
{description}
) : null}
{countLine ? (
{countLine}
) : null}
);
}
);
TagInput.displayName = 'TagInput';