UNPKG

@nopends-ui/tags-input

Version:

Tags Input is a component that allows users to input tags.

708 lines (704 loc) 23.8 kB
'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 };