diff --git a/packages/blade/src/components/Input/BaseInput/useTaggedInput.ts b/packages/blade/src/components/Input/BaseInput/useTaggedInput.ts new file mode 100644 index 00000000000..d68694d8dea --- /dev/null +++ b/packages/blade/src/components/Input/BaseInput/useTaggedInput.ts @@ -0,0 +1,111 @@ +import React from 'react'; +import type { BaseInputProps } from './BaseInput'; +import type { BladeElementRefWithValue } from '~utils/types'; +import type { FormInputOnKeyDownEvent } from '~components/Form/FormTypes'; +import { isReactNative } from '~utils'; +import { getTagsGroup } from '~components/Tag/getTagsGroup'; + +type TaggedInputProps = { + isTaggedInput?: boolean; + tags?: string[]; + onTagChange?: ({ tags }: { tags: string[] }) => void; +}; + +type UseTaggedInputProps = TaggedInputProps & + Pick & { + inputRef: React.RefObject; + }; + +const useTaggedInput = ({ + tags, + isDisabled, + onTagChange, + isTaggedInput, + inputRef, + onChange, + name, + value, +}: UseTaggedInputProps): { + activeTagIndex: number; + setActiveTagIndex: (activeTagIndex: number) => void; + getTags: ({ size }: { size: NonNullable }) => React.ReactElement[]; + handleTaggedInputKeydown: (e: FormInputOnKeyDownEvent) => void; +} => { + const [activeTagIndex, setActiveTagIndex] = React.useState(-1); + + const getNewTagsArray = (indexToRemove: number): string[] => { + if (!tags) { + return []; + } + + // Check if the index is valid + if (indexToRemove < 0 || indexToRemove >= tags.length) { + return tags; // Return the original array + } + + // Create a new array without the element at the specified index + const newArray = tags.slice(0, indexToRemove).concat(tags.slice(indexToRemove + 1)); + + return newArray; + }; + + const getTags = React.useMemo( + () => ({ size }: { size: NonNullable }): React.ReactElement[] => { + return getTagsGroup({ + size, + tags: tags ?? [], + activeTagIndex, + isDisabled, + onDismiss: ({ tagIndex }) => { + if (!isReactNative()) { + inputRef.current?.focus(); + } + onTagChange?.({ tags: getNewTagsArray(tagIndex) }); + }, + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [activeTagIndex, tags, isDisabled], + ); + + const handleTaggedInputKeydown = (e: FormInputOnKeyDownEvent): void => { + if (!isTaggedInput || !inputRef.current) { + return; + } + + const inputElement = inputRef.current; + + const currentTags = tags ?? []; + const isControlledValue = Boolean(value); + const inputValue = isControlledValue ? value?.trim() : inputElement.value.trim(); + if (e.key === 'Enter') { + e.event.preventDefault(); // we don't want textarea to treat enter as line break in tagged inputs + if (inputValue) { + onTagChange?.({ tags: [...currentTags, inputValue] }); + if (isControlledValue) { + onChange?.({ name, value: '' }); + } else { + inputElement.value = ''; + } + + setActiveTagIndex(-1); + } + } + + console.log({ activeTagIndex }); + + if (e.key === 'Backspace' && !inputValue && activeTagIndex < 0) { + onTagChange?.({ tags: currentTags.slice(0, -1) }); + } + }; + + return { + activeTagIndex, + setActiveTagIndex, + getTags, + handleTaggedInputKeydown, + }; +}; + +export type { TaggedInputProps }; +export { useTaggedInput }; diff --git a/packages/blade/src/components/Input/TextArea/TextArea.tsx b/packages/blade/src/components/Input/TextArea/TextArea.tsx index 9ce6cc0ad6e..1005887b47d 100644 --- a/packages/blade/src/components/Input/TextArea/TextArea.tsx +++ b/packages/blade/src/components/Input/TextArea/TextArea.tsx @@ -3,6 +3,8 @@ import React from 'react'; import type { TextInput as TextInputReactNative } from 'react-native'; import type { BaseInputProps } from '../BaseInput'; import { BaseInput } from '../BaseInput'; +import type { TaggedInputProps } from '../BaseInput/useTaggedInput'; +import { useTaggedInput } from '../BaseInput/useTaggedInput'; import isEmpty from '~utils/lodashButBetter/isEmpty'; import { CloseIcon } from '~components/Icons'; import { IconButton } from '~components/Button/IconButton'; @@ -13,9 +15,8 @@ import { CharacterCounter } from '~components/Form/CharacterCounter'; import { assignWithoutSideEffects } from '~utils/assignWithoutSideEffects'; import { getPlatformType } from '~utils'; import { useMergeRefs } from '~utils/useMergeRefs'; -import type { BladeElementRef } from '~utils/types'; +import type { BladeElementRef, BladeElementRefWithValue } from '~utils/types'; import { hintMarginTop } from '~components/Form/formTokens'; -import { getTagsGroup } from '~components/Tag/getTagsGroup'; type TextAreaCommonProps = Pick< BaseInputProps, @@ -51,11 +52,8 @@ type TextAreaCommonProps = Pick< * Event handler to handle the onClick event for clear button. Used when `showClearButton` is `true` */ onClearButtonClick?: () => void; - - isTaggedInput?: boolean; - tags?: string[]; - onTagChange?: ({ tags }: { tags: string[] }) => void; -} & StyledPropsBlade; +} & TaggedInputProps & + StyledPropsBlade; /* Mandatory accessibilityLabel prop when label is not provided @@ -129,8 +127,17 @@ const _TextArea: React.ForwardRefRenderFunction ) => { const inputRef = React.useRef(null); const mergedRef = useMergeRefs(ref, inputRef); - const [activeTagIndex, setActiveTagIndex] = React.useState(-1); - const [isInputFocussed, setIsInputFocussed] = React.useState(false); + const [isInputFocussed, setIsInputFocussed] = React.useState(autoFocus ?? false); + const { activeTagIndex, setActiveTagIndex, getTags, handleTaggedInputKeydown } = useTaggedInput({ + tags, + onTagChange, + isDisabled, + inputRef: inputRef as React.RefObject, + isTaggedInput, + name, + value, + onChange, + }); const [shouldShowClearButton, setShouldShowClearButton] = React.useState(false); @@ -169,49 +176,6 @@ const _TextArea: React.ForwardRefRenderFunction return null; }; - const getNewTagsArray = (indexToRemove: number): string[] => { - if (!tags) { - return []; - } - - // Check if the index is valid - if (indexToRemove < 0 || indexToRemove >= tags.length) { - return tags; // Return the original array - } - - // Create a new array without the element at the specified index - const newArray = tags.slice(0, indexToRemove).concat(tags.slice(indexToRemove + 1)); - - return newArray; - }; - - const getTags = React.useMemo( - () => ({ size }: { size: NonNullable }) => { - return getTagsGroup({ - size, - tags: tags ?? [], - activeTagIndex, - isDisabled, - onDismiss: ({ tagIndex }) => { - // if (isTagDismissedRef.current) { - // isTagDismissedRef.current.value = true; - // } - - if (!isReactNative(0)) { - inputRef.current?.focus(); - } - - onTagChange?.({ tags: getNewTagsArray(tagIndex) }); - - // removeOption(selectedIndices[tagIndex]); - // setChangeCallbackTriggerer(Number(changeCallbackTriggerer) + 1); - }, - }); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [activeTagIndex, tags, isDisabled], - ); - return ( onBlur?.(e); }} onKeyDown={(e) => { - if (!isTaggedInput) { - return; - } - - const currentTags = tags ?? []; - const isControlledValue = Boolean(value); - const inputElement = inputRef.current as HTMLTextAreaElement; - const inputValue = isControlledValue ? value?.trim() : inputElement.value.trim(); - if (e.key === 'Enter') { - e.event.preventDefault(); // we don't want textarea to treat enter as line break in tagged inputs - - if (inputValue) { - onTagChange?.({ tags: [...currentTags, inputValue] }); - if (isControlledValue) { - onChange?.({ name, value: '' }); - } else { - inputElement.value = ''; - } - - setActiveTagIndex(-1); - } - } - - if (e.key === 'Backspace' && !inputValue && activeTagIndex < 0) { - onTagChange?.({ tags: currentTags.slice(0, -1) }); - } + handleTaggedInputKeydown(e); }} onSubmit={onSubmit} trailingFooterSlot={(value) => { diff --git a/packages/blade/src/components/Input/TextInput/TextInput.stories.tsx b/packages/blade/src/components/Input/TextInput/TextInput.stories.tsx index eecad150e9d..9a815001b70 100644 --- a/packages/blade/src/components/Input/TextInput/TextInput.stories.tsx +++ b/packages/blade/src/components/Input/TextInput/TextInput.stories.tsx @@ -581,3 +581,20 @@ inputRef.parameters = { }, }, }; + +export const TextInputWithTags: StoryFn = ({ ...args }) => { + const [tags, setTags] = React.useState([]); + return ( + + { + setTags(tags); + }} + /> + + ); +}; diff --git a/packages/blade/src/components/Input/TextInput/TextInput.tsx b/packages/blade/src/components/Input/TextInput/TextInput.tsx index 003464fe2c8..d20bc5493ec 100644 --- a/packages/blade/src/components/Input/TextInput/TextInput.tsx +++ b/packages/blade/src/components/Input/TextInput/TextInput.tsx @@ -4,6 +4,8 @@ import type { TextInput as TextInputReactNative } from 'react-native'; import type { BaseInputProps } from '../BaseInput'; import { BaseInput } from '../BaseInput'; import { getKeyboardAndAutocompleteProps } from '../BaseInput/utils'; +import type { TaggedInputProps } from '../BaseInput/useTaggedInput'; +import { useTaggedInput } from '../BaseInput/useTaggedInput'; import isEmpty from '~utils/lodashButBetter/isEmpty'; import type { IconComponent } from '~components/Icons'; import { CloseIcon } from '~components/Icons'; @@ -16,7 +18,7 @@ import { Spinner } from '~components/Spinner'; import { assignWithoutSideEffects } from '~utils/assignWithoutSideEffects'; import { getPlatformType } from '~utils'; import { useMergeRefs } from '~utils/useMergeRefs'; -import type { BladeElementRef } from '~utils/types'; +import type { BladeElementRef, BladeElementRefWithValue } from '~utils/types'; import { hintMarginTop } from '~components/Form/formTokens'; // Users should use PasswordInput for input type password @@ -90,7 +92,8 @@ type TextInputCommonProps = Pick< * @default text */ type?: Type; -} & StyledPropsBlade; +} & TaggedInputProps & + StyledPropsBlade; /* Mandatory accessibilityLabel prop when label is not provided @@ -166,6 +169,9 @@ const _TextInput: React.ForwardRefRenderFunction(null); const mergedRef = useMergeRefs(ref, textInputRef); const [shouldShowClearButton, setShouldShowClearButton] = useState(false); + const [isInputFocussed, setIsInputFocussed] = useState(autoFocus ?? false); + const { activeTagIndex, setActiveTagIndex, getTags, handleTaggedInputKeydown } = useTaggedInput({ + isTaggedInput, + tags, + onTagChange, + isDisabled, + onChange, + name, + value, + inputRef: textInputRef as React.RefObject, + }); React.useEffect(() => { setShouldShowClearButton(Boolean(showClearButton && (defaultValue ?? value))); @@ -227,6 +244,12 @@ const _TextInput: React.ForwardRefRenderFunction { if (showClearButton && value?.length) { // show the clear button when the user starts typing in @@ -241,8 +264,17 @@ const _TextInput: React.ForwardRefRenderFunction { + setIsInputFocussed(true); + onFocus?.(e); + }} + onBlur={(e) => { + setIsInputFocussed(false); + onBlur?.(e); + }} + onKeyDown={(e) => { + handleTaggedInputKeydown(e); + }} onSubmit={onSubmit} isDisabled={isDisabled} necessityIndicator={necessityIndicator} diff --git a/packages/blade/src/utils/types.ts b/packages/blade/src/utils/types.ts index 70cc1c40194..1d9a83bbd08 100644 --- a/packages/blade/src/utils/types.ts +++ b/packages/blade/src/utils/types.ts @@ -131,6 +131,8 @@ type BladeElementRef = Platform.Select<{ native: View; }>; +type BladeElementRefWithValue = BladeElementRef & { value: string }; + type ContainerElementType = Platform.Select<{ web: HTMLDivElement; native: View; @@ -147,6 +149,7 @@ export type { PickIfExist, PickCSSByPlatform, BladeElementRef, + BladeElementRefWithValue, RemoveUndefinedFromUnion, ContainerElementType, };