@nopends-ui/tags-input
Version:
Tags Input is a component that allows users to input tags.
708 lines (704 loc) • 23.8 kB
JavaScript
'use client';
import * as React from 'react';
import { createContext, useControllableState, useId, useDirection, useItemCollection, useFormControl, useComposedRefs, DATA_ITEM_ATTR, Primitive, composeEventHandlers, VisuallyHiddenInput, composeRefs, Presence } from '@nopends-ui/shared';
var ROOT_NAME = "TagsInputRoot";
var [TagsInputProvider, useTagsInput] = createContext(ROOT_NAME);
var TagsInputRoot = React.forwardRef((props, ref) => {
const {
value: valueProp,
defaultValue,
onValueChange,
onValidate,
onInvalid,
displayValue = (value2) => value2.toString(),
addOnPaste = false,
addOnTab = false,
disabled = false,
editable = false,
loop = false,
blurBehavior,
delimiter = ",",
max = Number.POSITIVE_INFINITY,
readOnly = false,
required = false,
name,
children,
dir: dirProp,
id: idProp,
...rootProps
} = props;
const [value = [], setValue] = useControllableState({
prop: valueProp,
defaultProp: defaultValue,
onChange: onValueChange
});
const [highlightedIndex, setHighlightedIndex] = React.useState(
null
);
const [editingIndex, setEditingIndex] = React.useState(null);
const [isInvalidInput, setIsInvalidInput] = React.useState(false);
const collectionRef = React.useRef(null);
const inputRef = React.useRef(null);
const id = useId();
const inputId = useId();
const labelId = useId();
const dir = useDirection(dirProp);
const { getEnabledItems } = useItemCollection({
ref: collectionRef
});
const { isFormControl, onTriggerChange } = useFormControl();
const composedRef = useComposedRefs(
ref,
collectionRef,
(node) => onTriggerChange(node)
);
const onItemAdd = React.useCallback(
(textValue, options) => {
if (disabled || readOnly) return false;
if (addOnPaste && options?.viaPaste) {
const splitValues = textValue.split(delimiter).map((v) => v.trim()).filter(Boolean);
if (value.length + splitValues.length > max && max > 0) {
onInvalid?.(textValue);
return false;
}
let newValues2 = [];
for (const v of splitValues) {
if (value.includes(v)) {
onInvalid?.(v);
}
}
newValues2 = [...new Set(splitValues.filter((v) => !value.includes(v)))];
const validValues = newValues2.filter(
(v) => !onValidate || onValidate(v)
);
if (validValues.length === 0) return false;
setValue([...value, ...validValues]);
return true;
}
if (value.length >= max && max > 0) {
onInvalid?.(textValue);
return false;
}
const trimmedValue = textValue.trim();
if (onValidate && !onValidate(trimmedValue)) {
setIsInvalidInput(true);
onInvalid?.(trimmedValue);
return false;
}
const exists = value.some((v) => {
const valueToCompare = v;
return valueToCompare === trimmedValue;
});
if (exists) {
setIsInvalidInput(true);
onInvalid?.(trimmedValue);
return true;
}
const newValue = trimmedValue;
const newValues = [...value, newValue];
setValue(newValues);
setHighlightedIndex(null);
setEditingIndex(null);
setIsInvalidInput(false);
return true;
},
[
value,
max,
addOnPaste,
delimiter,
setValue,
onInvalid,
onValidate,
disabled,
readOnly
]
);
const onItemUpdate = React.useCallback(
(index, newTextValue) => {
if (disabled || readOnly) return;
if (index !== -1) {
const trimmedValue = newTextValue.trim();
const exists = value.some((v, i) => {
if (i === index) return false;
const valueToCompare = v;
return valueToCompare === trimmedValue;
});
if (exists) {
setIsInvalidInput(true);
onInvalid?.(trimmedValue);
return;
}
if (onValidate && !onValidate(trimmedValue)) {
setIsInvalidInput(true);
onInvalid?.(trimmedValue);
return;
}
const updatedValue = displayValue(trimmedValue);
const newValues = [...value];
newValues[index] = updatedValue;
setValue(newValues);
setHighlightedIndex(index);
setEditingIndex(null);
setIsInvalidInput(false);
requestAnimationFrame(() => inputRef.current?.focus());
}
},
[value, setValue, displayValue, onInvalid, onValidate, disabled, readOnly]
);
const onItemRemove = React.useCallback(
(index) => {
if (disabled || readOnly) return;
if (index !== -1) {
const newValues = [...value];
newValues.splice(index, 1);
setValue(newValues);
setHighlightedIndex(null);
setEditingIndex(null);
inputRef.current?.focus();
}
},
[value, setValue, disabled, readOnly]
);
const onItemLeave = React.useCallback(() => {
setHighlightedIndex(null);
setEditingIndex(null);
inputRef.current?.focus();
}, []);
const onInputKeydown = React.useCallback(
(event) => {
const target = event.target;
if (!(target instanceof HTMLInputElement)) return;
const isArrowLeft = event.key === "ArrowLeft" && dir === "ltr" || event.key === "ArrowRight" && dir === "rtl";
const isArrowRight = event.key === "ArrowRight" && dir === "ltr" || event.key === "ArrowLeft" && dir === "rtl";
if (target.value && target.selectionStart !== 0) {
setHighlightedIndex(null);
setEditingIndex(null);
return;
}
function findNextEnabledIndex(currentIndex, direction) {
const collectionElement = collectionRef.current;
if (!collectionElement) return null;
const enabledItems = getEnabledItems();
const enabledIndices = enabledItems.map((_, index) => index);
if (enabledIndices.length === 0) return null;
if (currentIndex === null) {
return direction === "prev" ? enabledIndices[enabledIndices.length - 1] ?? null : enabledIndices[0] ?? null;
}
const currentEnabledIndex = enabledIndices.indexOf(currentIndex);
if (direction === "next") {
return currentEnabledIndex >= enabledIndices.length - 1 ? loop ? enabledIndices[0] ?? null : null : enabledIndices[currentEnabledIndex + 1] ?? null;
}
return currentEnabledIndex <= 0 ? loop ? enabledIndices[enabledIndices.length - 1] ?? null : null : enabledIndices[currentEnabledIndex - 1] ?? null;
}
switch (event.key) {
case "Delete":
case "Backspace": {
if (target.selectionStart !== 0 || target.selectionEnd !== 0) break;
if (highlightedIndex !== null) {
const newIndex = findNextEnabledIndex(highlightedIndex, "prev");
onItemRemove(highlightedIndex);
setHighlightedIndex(newIndex);
event.preventDefault();
} else if (event.key === "Backspace" && value.length > 0) {
const lastIndex = findNextEnabledIndex(null, "prev");
setHighlightedIndex(lastIndex);
event.preventDefault();
}
break;
}
case "Enter": {
if (highlightedIndex !== null && editable && !disabled) {
setEditingIndex(highlightedIndex);
event.preventDefault();
return;
}
break;
}
case "ArrowLeft":
case "ArrowRight": {
if (target.selectionStart === 0 && isArrowLeft && highlightedIndex === null && value.length > 0) {
const lastIndex = findNextEnabledIndex(null, "prev");
setHighlightedIndex(lastIndex);
event.preventDefault();
} else if (highlightedIndex !== null) {
const nextIndex = findNextEnabledIndex(
highlightedIndex,
isArrowLeft ? "prev" : "next"
);
if (nextIndex !== null) {
setHighlightedIndex(nextIndex);
event.preventDefault();
} else if (isArrowRight) {
setHighlightedIndex(null);
requestAnimationFrame(() => target.setSelectionRange(0, 0));
}
}
break;
}
case "Home": {
if (highlightedIndex !== null) {
const firstIndex = findNextEnabledIndex(null, "next");
setHighlightedIndex(firstIndex);
event.preventDefault();
}
break;
}
case "End": {
if (highlightedIndex !== null) {
const lastIndex = findNextEnabledIndex(null, "prev");
setHighlightedIndex(lastIndex);
event.preventDefault();
}
break;
}
case "Escape": {
if (highlightedIndex !== null) setHighlightedIndex(null);
if (editingIndex !== null) setEditingIndex(null);
requestAnimationFrame(() => target.setSelectionRange(0, 0));
break;
}
}
},
[
dir,
editingIndex,
highlightedIndex,
value,
onItemRemove,
getEnabledItems,
editable,
disabled,
loop
]
);
const getIsClickedInEmptyRoot = React.useCallback((target) => {
return collectionRef.current?.contains(target) && !target.hasAttribute(DATA_ITEM_ATTR) && target.tagName !== "INPUT";
}, []);
return /* @__PURE__ */ React.createElement(
TagsInputProvider,
{
value,
onValueChange: setValue,
onItemAdd,
onItemRemove,
onItemUpdate,
onInputKeydown,
highlightedIndex,
setHighlightedIndex,
editingIndex,
setEditingIndex,
displayValue,
onItemLeave,
inputRef,
isInvalidInput,
addOnPaste,
addOnTab,
disabled,
editable,
loop,
readOnly,
blurBehavior,
delimiter,
max,
dir,
id,
inputId,
labelId
},
/* @__PURE__ */ React.createElement(
Primitive.div,
{
id,
"data-disabled": disabled ? "" : void 0,
"data-invalid": isInvalidInput ? "" : void 0,
"data-readonly": readOnly ? "" : void 0,
dir,
...rootProps,
ref: composedRef,
onClick: composeEventHandlers(rootProps.onClick, (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
if (getIsClickedInEmptyRoot(target) && document.activeElement !== inputRef.current) {
event.currentTarget.focus();
inputRef.current?.focus();
}
}),
onMouseDown: composeEventHandlers(rootProps.onMouseDown, (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
if (getIsClickedInEmptyRoot(target)) {
event.preventDefault();
}
}),
onBlur: composeEventHandlers(rootProps.onBlur, (event) => {
if (event.relatedTarget !== inputRef.current && !collectionRef.current?.contains(event.relatedTarget)) {
requestAnimationFrame(() => setHighlightedIndex(null));
}
})
},
typeof children === "function" ? /* @__PURE__ */ React.createElement(React.Fragment, null, children({ value })) : children,
isFormControl && name && /* @__PURE__ */ React.createElement(
VisuallyHiddenInput,
{
type: "hidden",
control: collectionRef.current,
name,
value,
disabled,
required
}
)
)
);
});
TagsInputRoot.displayName = ROOT_NAME;
var Root = TagsInputRoot;
var LABEL_NAME = "TagsInputLabel";
var TagsInputLabel = React.forwardRef(
(props, ref) => {
const context = useTagsInput(LABEL_NAME);
return /* @__PURE__ */ React.createElement(
Primitive.label,
{
id: context.labelId,
htmlFor: context.inputId,
...props,
ref
}
);
}
);
TagsInputLabel.displayName = LABEL_NAME;
var Label = TagsInputLabel;
var ITEM_NAME = "TagsInputItem";
var [TagsInputItemProvider, useTagsInputItem] = createContext(ITEM_NAME);
var TagsInputItem = React.forwardRef(
(props, ref) => {
const { value, disabled, ...itemProps } = props;
const pointerTypeRef = React.useRef("touch");
const context = useTagsInput(ITEM_NAME);
const id = useId();
const textId = `${id}text`;
const index = context.value.indexOf(value);
const isHighlighted = index === context.highlightedIndex;
const isEditing = index === context.editingIndex;
const itemDisabled = disabled || context.disabled;
const displayValue = context.displayValue(value);
const onItemSelect = React.useCallback(() => {
context.setHighlightedIndex(index);
context.inputRef.current?.focus();
}, [context.setHighlightedIndex, context.inputRef, index]);
return /* @__PURE__ */ React.createElement(
TagsInputItemProvider,
{
id,
value,
index,
isHighlighted,
isEditing,
disabled: itemDisabled,
textId,
displayValue
},
/* @__PURE__ */ React.createElement(
Primitive.div,
{
id,
"aria-labelledby": textId,
"aria-current": isHighlighted,
"aria-disabled": itemDisabled,
...{ [DATA_ITEM_ATTR]: "" },
"data-state": isHighlighted ? "active" : "inactive",
"data-highlighted": isHighlighted ? "" : void 0,
"data-editing": isEditing ? "" : void 0,
"data-editable": context.editable ? "" : void 0,
"data-disabled": itemDisabled ? "" : void 0,
...itemProps,
ref,
onClick: composeEventHandlers(itemProps.onClick, (event) => {
event.stopPropagation();
if (!isEditing && pointerTypeRef.current !== "mouse") {
onItemSelect();
}
}),
onDoubleClick: composeEventHandlers(itemProps.onDoubleClick, () => {
if (context.editable && !itemDisabled) {
requestAnimationFrame(() => context.setEditingIndex(index));
}
}),
onPointerUp: composeEventHandlers(itemProps.onPointerUp, () => {
if (pointerTypeRef.current === "mouse") onItemSelect();
}),
onPointerDown: composeEventHandlers(
itemProps.onPointerDown,
(event) => pointerTypeRef.current = event.pointerType
),
onPointerMove: composeEventHandlers(
itemProps.onPointerMove,
(event) => {
pointerTypeRef.current = event.pointerType;
if (disabled) {
context.onItemLeave();
} else if (pointerTypeRef.current === "mouse") {
event.currentTarget.focus({ preventScroll: true });
}
}
),
onPointerLeave: composeEventHandlers(
itemProps.onPointerLeave,
(event) => {
if (event.currentTarget === document.activeElement) {
context.onItemLeave();
}
}
)
}
)
);
}
);
TagsInputItem.displayName = ITEM_NAME;
var Item = TagsInputItem;
var INPUT_NAME = "TagsInputInput";
var TagsInputInput = React.forwardRef(
(props, ref) => {
const { autoFocus, ...inputProps } = props;
const context = useTagsInput(INPUT_NAME);
const onTab = React.useCallback(
(event) => {
if (!context.addOnTab) return;
onCustomKeydown(event);
},
[context.addOnTab]
);
const onCustomKeydown = React.useCallback(
(event) => {
if (event.defaultPrevented) return;
const value = event.currentTarget.value;
if (!value) return;
const isAdded = context.onItemAdd(value);
if (isAdded) {
event.currentTarget.value = "";
context.setHighlightedIndex(null);
}
event.preventDefault();
},
[context.onItemAdd, context.setHighlightedIndex]
);
React.useEffect(() => {
if (!autoFocus) return;
const animationFrameId = requestAnimationFrame(
() => context.inputRef.current?.focus()
);
return () => cancelAnimationFrame(animationFrameId);
}, [autoFocus, context.inputRef]);
return /* @__PURE__ */ React.createElement(
Primitive.input,
{
type: "text",
id: context.inputId,
autoCapitalize: "off",
autoComplete: "off",
autoCorrect: "off",
spellCheck: "false",
autoFocus,
"aria-labelledby": context.labelId,
"aria-readonly": context.readOnly,
"data-invalid": context.isInvalidInput ? "" : void 0,
dir: context.dir,
disabled: context.disabled,
readOnly: context.readOnly,
...inputProps,
ref: composeRefs(context.inputRef, ref),
onBlur: composeEventHandlers(inputProps.onBlur, (event) => {
if (context.readOnly) return;
if (context.blurBehavior === "add") {
const value = event.target.value;
if (value) {
const isAdded = context.onItemAdd(value);
if (isAdded) event.target.value = "";
}
}
if (context.blurBehavior === "clear") {
event.target.value = "";
}
}),
onChange: composeEventHandlers(inputProps.onChange, (event) => {
if (context.readOnly) return;
const target = event.target;
if (!(target instanceof HTMLInputElement)) return;
const delimiter = context.delimiter;
if (delimiter === target.value.slice(-1)) {
const value = target.value.slice(0, -1);
target.value = "";
if (value) {
context.onItemAdd(value);
context.setHighlightedIndex(null);
}
}
}),
onKeyDown: composeEventHandlers(inputProps.onKeyDown, (event) => {
if (context.readOnly) return;
if (event.key === "Enter") onCustomKeydown(event);
if (event.key === "Tab") onTab(event);
context.onInputKeydown(event);
if (event.key.length === 1) context.setHighlightedIndex(null);
}),
onPaste: composeEventHandlers(inputProps.onPaste, (event) => {
if (context.readOnly) return;
if (context.addOnPaste) {
event.preventDefault();
const value = event.clipboardData.getData("text");
context.onItemAdd(value, { viaPaste: true });
context.setHighlightedIndex(null);
}
})
}
);
}
);
TagsInputInput.displayName = INPUT_NAME;
var Input = TagsInputInput;
var ITEM_DELETE_NAME = "TagsInputItemDelete";
var TagsInputItemDelete = React.forwardRef((props, ref) => {
const context = useTagsInput(ITEM_DELETE_NAME);
const itemContext = useTagsInputItem(ITEM_DELETE_NAME);
const disabled = itemContext.disabled || context.disabled;
if (itemContext.isEditing) return null;
return /* @__PURE__ */ React.createElement(
Primitive.button,
{
type: "button",
tabIndex: disabled ? void 0 : -1,
"aria-labelledby": itemContext.textId,
"aria-controls": itemContext.id,
"aria-current": itemContext.isHighlighted,
"data-state": itemContext.isHighlighted ? "active" : "inactive",
"data-disabled": disabled ? "" : void 0,
...props,
ref,
onClick: composeEventHandlers(props.onClick, () => {
if (disabled) return;
const index = context.value.findIndex((i) => i === itemContext.value);
context.onItemRemove(index);
})
}
);
});
TagsInputItemDelete.displayName = ITEM_DELETE_NAME;
var ItemDelete = TagsInputItemDelete;
var ITEM_TEXT_NAME = "TagsInputItemText";
var TagsInputItemText = React.forwardRef((props, ref) => {
const { children, ...itemTextProps } = props;
const context = useTagsInput(ITEM_TEXT_NAME);
const itemContext = useTagsInputItem(ITEM_TEXT_NAME);
if (itemContext.isEditing && context.editable && !itemContext.disabled) {
return /* @__PURE__ */ React.createElement(TagsInputEditableItemText, null);
}
return /* @__PURE__ */ React.createElement(Primitive.span, { id: itemContext.textId, ...itemTextProps, ref }, children ?? itemContext.displayValue);
});
var ItemText = TagsInputItemText;
function TagsInputEditableItemText() {
const context = useTagsInput(ITEM_TEXT_NAME);
const itemContext = useTagsInputItem(ITEM_TEXT_NAME);
const [editValue, setEditValue] = React.useState(itemContext.displayValue);
const onBlur = React.useCallback(() => {
setEditValue(itemContext.displayValue);
context.setEditingIndex(null);
}, [context.setEditingIndex, itemContext.displayValue]);
const onChange = React.useCallback(
(event) => {
const target = event.target;
target.style.width = "0";
target.style.width = `${target.scrollWidth + 4}px`;
setEditValue(event.target.value);
},
[]
);
const onFocus = React.useCallback(
(event) => {
event.target.select();
event.target.style.width = "0";
event.target.style.width = `${event.target.scrollWidth + 4}px`;
},
[]
);
const onKeyDown = React.useCallback(
(event) => {
if (event.key === "Enter") {
const index = context.value.findIndex((v) => v === itemContext.value);
context.onItemUpdate(index, editValue);
} else if (event.key === "Escape") {
setEditValue(itemContext.displayValue);
context.setEditingIndex(null);
context.setHighlightedIndex(itemContext.index);
context.inputRef.current?.focus();
}
event.stopPropagation();
},
[
context.value,
context.onItemUpdate,
context.setEditingIndex,
itemContext.displayValue,
context.inputRef.current?.focus,
editValue,
itemContext.value,
context.setHighlightedIndex,
itemContext.index
]
);
return /* @__PURE__ */ React.createElement(
Primitive.input,
{
type: "text",
autoCapitalize: "off",
autoComplete: "off",
autoCorrect: "off",
spellCheck: "false",
autoFocus: true,
"aria-describedby": itemContext.textId,
value: editValue,
onChange,
onKeyDown,
onFocus,
onBlur,
style: {
outline: "none",
background: "inherit",
border: "none",
font: "inherit",
color: "inherit",
padding: 0,
minWidth: "1ch"
}
}
);
}
var CLEAR_NAME = "TagsInputClear";
var TagsInputClear = React.forwardRef(
(props, ref) => {
const { forceMount, ...clearProps } = props;
const context = useTagsInput(CLEAR_NAME);
return /* @__PURE__ */ React.createElement(Presence, { present: forceMount || context.value.length > 0 }, /* @__PURE__ */ React.createElement(
Primitive.button,
{
type: "button",
"aria-disabled": context.disabled,
"data-state": context.value.length > 0 ? "visible" : "invisible",
"data-disabled": context.disabled ? "" : void 0,
...clearProps,
ref,
onClick: composeEventHandlers(props.onClick, () => {
if (context.disabled) return;
context.onValueChange([]);
context.inputRef.current?.focus();
})
}
));
}
);
TagsInputClear.displayName = CLEAR_NAME;
var Clear = TagsInputClear;
export { Clear, Input, Item, ItemDelete, ItemText, Label, Root, TagsInputClear, TagsInputInput, TagsInputItem, TagsInputItemDelete, TagsInputItemText, TagsInputLabel, TagsInputRoot };