(items: T[], search: string) => {
+ if (!search) {
+ return items;
+ }
+
+ const scoredItems = items.map((item) => ({
+ item,
+ score: fuzzySearchScore(item, search),
+ }));
+
+ const filteredItems = scoredItems
+ .filter(({score}) => score)
+ .sort((a, b) => {
+ const compare = a.score - b.score;
+
+ if (compare === 0) {
+ return a.item.search.localeCompare(b.item.search);
+ }
+
+ return compare;
+ });
+
+ return sortSuggestions(filteredItems.map(({item}) => item));
+};
diff --git a/src/components/TokenizedInput/components/Tokens/Token/NewToken.tsx b/src/components/TokenizedInput/components/Tokens/Token/NewToken.tsx
new file mode 100644
index 00000000..f315c146
--- /dev/null
+++ b/src/components/TokenizedInput/components/Tokens/Token/NewToken.tsx
@@ -0,0 +1,33 @@
+import {useNewToken} from './hooks';
+import type {TokenBaseProps} from './types';
+
+export type NewTokenProps = TokenBaseProps;
+
+export function NewToken({idx}: NewTokenProps) {
+ const newTokenInfo = useNewToken(idx);
+
+ const {token, fields, Field, classNames} = newTokenInfo.state;
+ const {onChangeField, onFocusField, checkIsHidden, checkIsAutoFocus, getPlaceholder} =
+ newTokenInfo.callbacks;
+
+ return (
+
+ {fields.map(({key}, i) => {
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/TokenizedInput/components/Tokens/Token/RegularToken.tsx b/src/components/TokenizedInput/components/Tokens/Token/RegularToken.tsx
new file mode 100644
index 00000000..7d71bd65
--- /dev/null
+++ b/src/components/TokenizedInput/components/Tokens/Token/RegularToken.tsx
@@ -0,0 +1,47 @@
+import {Xmark} from '@gravity-ui/icons';
+
+import {useRegularToken} from './hooks';
+import type {TokenBaseProps} from './types';
+
+export type RegularTokenProps = TokenBaseProps;
+
+export function RegularToken({idx}: RegularTokenProps) {
+ const regularTokenInfo = useRegularToken(idx);
+
+ const {token, fields, showRemoveButton, Field, classNames, isEditable} = regularTokenInfo.state;
+ const {onChangeField, onFocusField, onBlur, onRemove, getPlaceholder} =
+ regularTokenInfo.callbacks;
+
+ return (
+
+ {fields.map(({className, key}, index) => {
+ return (
+
+ );
+ })}
+ {showRemoveButton && (
+
+ )}
+
+ );
+}
diff --git a/src/components/TokenizedInput/components/Tokens/Token/Token.tsx b/src/components/TokenizedInput/components/Tokens/Token/Token.tsx
new file mode 100644
index 00000000..179cc123
--- /dev/null
+++ b/src/components/TokenizedInput/components/Tokens/Token/Token.tsx
@@ -0,0 +1,32 @@
+import {NewToken as NewTokenComponent, NewTokenProps} from './NewToken';
+import {RegularToken as RegularTokenComponent, RegularTokenProps} from './RegularToken';
+
+export type TokenProps = (
+ | ({isNew: true} & NewTokenProps)
+ | ({isNew?: false} & RegularTokenProps)
+) & {
+ NewToken?: typeof NewTokenComponent;
+ RegularToken?: typeof RegularTokenComponent;
+};
+
+const TokenComponent = ({
+ NewToken = NewTokenComponent,
+ RegularToken = RegularTokenComponent,
+ ...props
+}: TokenProps) => {
+ if (props.isNew === true) {
+ return ;
+ }
+
+ return ;
+};
+
+type TToken = typeof TokenComponent & {
+ Regular: typeof RegularTokenComponent;
+ New: typeof NewTokenComponent;
+};
+
+export const Token = TokenComponent as TToken;
+
+Token.Regular = RegularTokenComponent;
+Token.New = NewTokenComponent;
diff --git a/src/components/TokenizedInput/components/Tokens/Token/hooks/index.ts b/src/components/TokenizedInput/components/Tokens/Token/hooks/index.ts
new file mode 100644
index 00000000..ac5dd0ea
--- /dev/null
+++ b/src/components/TokenizedInput/components/Tokens/Token/hooks/index.ts
@@ -0,0 +1,3 @@
+export {useTokenCallbacks} from './useTokenCallbacks';
+export {useNewToken} from './useNewToken';
+export {useRegularToken} from './useRegularToken';
diff --git a/src/components/TokenizedInput/components/Tokens/Token/hooks/useNewToken.ts b/src/components/TokenizedInput/components/Tokens/Token/hooks/useNewToken.ts
new file mode 100644
index 00000000..04febd1e
--- /dev/null
+++ b/src/components/TokenizedInput/components/Tokens/Token/hooks/useNewToken.ts
@@ -0,0 +1,76 @@
+import * as React from 'react';
+
+import {b} from '../../../../constants';
+import {useTokenizedInput, useTokenizedInputComponents} from '../../../../context';
+import {getDefaultTokenValue} from '../../../../utils';
+
+import {useTokenCallbacks} from './useTokenCallbacks';
+
+export const useNewToken = (idx: number) => {
+ const {inputInfo, focusInfo} = useTokenizedInput();
+ const {Field} = useTokenizedInputComponents();
+
+ const {tokens, fields, placeholder} = inputInfo.state;
+ const {autoFocus} = focusInfo.state;
+
+ const {onChangeField, onFocusField} = useTokenCallbacks();
+
+ const token = React.useMemo(
+ () => tokens[idx] ?? {id: 'new-token', value: getDefaultTokenValue(fields), isNew: true},
+ [fields, idx, tokens],
+ );
+
+ const checkIsHidden = React.useCallback(
+ (i: number) => i > 0 && !token.value[fields[i - 1].key],
+ [fields, token.value],
+ );
+
+ const checkIsAutoFocus = React.useCallback((i: number) => i === 0 && autoFocus, [autoFocus]);
+
+ const getPlaceholder = React.useCallback(
+ (i: number) => {
+ if (typeof placeholder === 'function') {
+ return placeholder('new', token.value, i);
+ }
+
+ return i === 0 ? placeholder : undefined;
+ },
+ [placeholder, token],
+ );
+
+ const classNames = React.useMemo(
+ () => ({
+ wrapper: b('token-wrapper', {new: true}),
+ }),
+ [],
+ );
+
+ return React.useMemo(
+ () => ({
+ state: {
+ token,
+ fields,
+ Field,
+ classNames,
+ },
+ callbacks: {
+ onChangeField,
+ onFocusField,
+ getPlaceholder,
+ checkIsHidden,
+ checkIsAutoFocus,
+ },
+ }),
+ [
+ Field,
+ checkIsAutoFocus,
+ checkIsHidden,
+ classNames,
+ fields,
+ getPlaceholder,
+ onChangeField,
+ onFocusField,
+ token,
+ ],
+ );
+};
diff --git a/src/components/TokenizedInput/components/Tokens/Token/hooks/useRegularToken.ts b/src/components/TokenizedInput/components/Tokens/Token/hooks/useRegularToken.ts
new file mode 100644
index 00000000..58bc7973
--- /dev/null
+++ b/src/components/TokenizedInput/components/Tokens/Token/hooks/useRegularToken.ts
@@ -0,0 +1,97 @@
+import * as React from 'react';
+
+import {b} from '../../../../constants';
+import {useTokenizedInput, useTokenizedInputComponents} from '../../../../context';
+import {useApplyCallbackOnBlur} from '../../../../hooks';
+
+import {useTokenCallbacks} from './useTokenCallbacks';
+
+export const useRegularToken = (idx: number) => {
+ const {inputInfo} = useTokenizedInput();
+ const {Field} = useTokenizedInputComponents();
+
+ const {tokens, isEditable, fields, placeholder} = inputInfo.state;
+ const {onApplyChanges, onRemoveToken} = inputInfo.callbacks;
+ const {onChangeField, onFocusField} = useTokenCallbacks();
+
+ const token = tokens[idx];
+
+ const hasChanges = React.useRef(false);
+
+ const handleChangeField = React.useCallback(
+ (index: number, key: string, value: string) => {
+ hasChanges.current = true;
+ onChangeField(index, key, value);
+ },
+ [onChangeField],
+ );
+
+ const blurCallback = React.useCallback(() => {
+ if (hasChanges.current) {
+ onApplyChanges(true);
+ hasChanges.current = false;
+ }
+ }, [onApplyChanges]);
+ const onBlur = useApplyCallbackOnBlur(blurCallback);
+
+ const showRemoveButton = !token.options?.notRemovable;
+
+ const onRemove = React.useCallback(() => {
+ onRemoveToken(idx);
+ onFocusField(idx, fields[0].key);
+ }, [fields, idx, onFocusField, onRemoveToken]);
+
+ const classNames = React.useMemo(
+ () => ({
+ wrapper: b('token-wrapper', {
+ error: Boolean(Object.keys(token.errors ?? {}).length),
+ }),
+ removeButton: b('token-remove-button'),
+ }),
+ [token.errors],
+ );
+
+ const getPlaceholder = React.useCallback(
+ (i: number) => {
+ if (typeof placeholder !== 'function') {
+ return undefined;
+ }
+
+ return placeholder('regular', token.value, i);
+ },
+ [placeholder, token],
+ );
+
+ return React.useMemo(
+ () => ({
+ state: {
+ token,
+ fields,
+ showRemoveButton,
+ Field,
+ isEditable,
+ classNames,
+ },
+ callbacks: {
+ onChangeField: handleChangeField,
+ onFocusField,
+ onRemove,
+ onBlur,
+ getPlaceholder,
+ },
+ }),
+ [
+ Field,
+ classNames,
+ fields,
+ handleChangeField,
+ isEditable,
+ onBlur,
+ onFocusField,
+ onRemove,
+ showRemoveButton,
+ token,
+ getPlaceholder,
+ ],
+ );
+};
diff --git a/src/components/TokenizedInput/components/Tokens/Token/hooks/useTokenCallbacks.ts b/src/components/TokenizedInput/components/Tokens/Token/hooks/useTokenCallbacks.ts
new file mode 100644
index 00000000..700edcb6
--- /dev/null
+++ b/src/components/TokenizedInput/components/Tokens/Token/hooks/useTokenCallbacks.ts
@@ -0,0 +1,34 @@
+import * as React from 'react';
+
+import {useTokenizedInput} from '../../../../context';
+
+export const useTokenCallbacks = () => {
+ const {inputInfo, focusInfo} = useTokenizedInput();
+
+ const {onChangeToken} = inputInfo.callbacks;
+ const {onFocus} = focusInfo.callbacks;
+
+ const onChangeField = React.useCallback(
+ (idx: number, key: string, value: string) => {
+ onChangeToken(idx, {
+ [key]: value,
+ });
+ },
+ [onChangeToken],
+ );
+
+ const onFocusField = React.useCallback(
+ (idx: number, key: string) => {
+ onFocus({
+ idx,
+ key,
+ });
+ },
+ [onFocus],
+ );
+
+ return {
+ onChangeField,
+ onFocusField,
+ };
+};
diff --git a/src/components/TokenizedInput/components/Tokens/Token/index.ts b/src/components/TokenizedInput/components/Tokens/Token/index.ts
new file mode 100644
index 00000000..44b24fae
--- /dev/null
+++ b/src/components/TokenizedInput/components/Tokens/Token/index.ts
@@ -0,0 +1,6 @@
+export {Token as TokenComponent} from './Token';
+export type {TokenProps as TokenizedInputTokenProps} from './Token';
+export {
+ useNewToken as useTokenizedInputNewToken,
+ useRegularToken as useTokenizedInputRegularToken,
+} from './hooks';
diff --git a/src/components/TokenizedInput/components/Tokens/Token/types.ts b/src/components/TokenizedInput/components/Tokens/Token/types.ts
new file mode 100644
index 00000000..d205ffa2
--- /dev/null
+++ b/src/components/TokenizedInput/components/Tokens/Token/types.ts
@@ -0,0 +1,3 @@
+export interface TokenBaseProps {
+ idx: number;
+}
diff --git a/src/components/TokenizedInput/components/Tokens/TokenList/TokenList.tsx b/src/components/TokenizedInput/components/Tokens/TokenList/TokenList.tsx
new file mode 100644
index 00000000..b3f6093f
--- /dev/null
+++ b/src/components/TokenizedInput/components/Tokens/TokenList/TokenList.tsx
@@ -0,0 +1,18 @@
+import {useTokenList} from './useTokenList';
+
+export function TokenList() {
+ const {Token, tokens, newTokenIdx, classNames} = useTokenList();
+
+ return (
+
+ {tokens.map((token, idx) => {
+ if (token.isNew) {
+ return null;
+ }
+
+ return ;
+ })}
+
+
+ );
+}
diff --git a/src/components/TokenizedInput/components/Tokens/TokenList/index.ts b/src/components/TokenizedInput/components/Tokens/TokenList/index.ts
new file mode 100644
index 00000000..a95f84b3
--- /dev/null
+++ b/src/components/TokenizedInput/components/Tokens/TokenList/index.ts
@@ -0,0 +1,2 @@
+export {TokenList as TokenListComponent} from './TokenList';
+export {useTokenList as useTokenizedInputList} from './useTokenList';
diff --git a/src/components/TokenizedInput/components/Tokens/TokenList/useTokenList.ts b/src/components/TokenizedInput/components/Tokens/TokenList/useTokenList.ts
new file mode 100644
index 00000000..4a72156e
--- /dev/null
+++ b/src/components/TokenizedInput/components/Tokens/TokenList/useTokenList.ts
@@ -0,0 +1,19 @@
+import * as React from 'react';
+
+import {b} from '../../../constants';
+import {useTokenizedInput, useTokenizedInputComponents} from '../../../context';
+
+export const useTokenList = () => {
+ const {inputInfo} = useTokenizedInput();
+ const {Token} = useTokenizedInputComponents();
+
+ const {tokens} = inputInfo.state;
+
+ const newTokenIdx = tokens.filter((t) => !t.isNew).length;
+ const classNames = React.useMemo(() => ({wrapper: b('token-list')}), []);
+
+ return React.useMemo(
+ () => ({Token, tokens, newTokenIdx, classNames}),
+ [Token, classNames, newTokenIdx, tokens],
+ );
+};
diff --git a/src/components/TokenizedInput/components/Tokens/index.ts b/src/components/TokenizedInput/components/Tokens/index.ts
new file mode 100644
index 00000000..44f1aa37
--- /dev/null
+++ b/src/components/TokenizedInput/components/Tokens/index.ts
@@ -0,0 +1,2 @@
+export * from './Token';
+export * from './TokenList';
diff --git a/src/components/TokenizedInput/components/Wrapper/Wrapper.tsx b/src/components/TokenizedInput/components/Wrapper/Wrapper.tsx
new file mode 100644
index 00000000..c319dc61
--- /dev/null
+++ b/src/components/TokenizedInput/components/Wrapper/Wrapper.tsx
@@ -0,0 +1,29 @@
+/* eslint-disable jsx-a11y/no-static-element-interactions */
+import * as React from 'react';
+
+import {Xmark} from '@gravity-ui/icons';
+
+import {useWrapper} from './hooks';
+
+export function Wrapper({children}: React.PropsWithChildren) {
+ const wrapperInfo = useWrapper();
+
+ const {isClearable, classNames, wrapperRef} = wrapperInfo.state;
+ const {onBlur, onKeyDown, onClear} = wrapperInfo.callbacks;
+
+ return (
+
+ {children}
+ {isClearable && (
+
+ )}
+
+ );
+}
diff --git a/src/components/TokenizedInput/components/Wrapper/hooks/index.ts b/src/components/TokenizedInput/components/Wrapper/hooks/index.ts
new file mode 100644
index 00000000..ebda5477
--- /dev/null
+++ b/src/components/TokenizedInput/components/Wrapper/hooks/index.ts
@@ -0,0 +1 @@
+export {useWrapper} from './useWrapper';
diff --git a/src/components/TokenizedInput/components/Wrapper/hooks/useKeyDownHandler.ts b/src/components/TokenizedInput/components/Wrapper/hooks/useKeyDownHandler.ts
new file mode 100644
index 00000000..5e4a21d7
--- /dev/null
+++ b/src/components/TokenizedInput/components/Wrapper/hooks/useKeyDownHandler.ts
@@ -0,0 +1,501 @@
+import * as React from 'react';
+
+import {KeyCode} from '../../../constants';
+import {useTokenizedInput} from '../../../context';
+import type {Token, TokenValueBase} from '../../../types';
+
+type ShortcutMap = {
+ isTokenModifier: (e: React.KeyboardEvent) => boolean;
+ isFieldModifier: (e: React.KeyboardEvent) => boolean;
+ isApplyModifier: (e: React.KeyboardEvent) => boolean;
+ isUndo: (e: React.KeyboardEvent) => boolean;
+ isRedo: (e: React.KeyboardEvent) => boolean;
+};
+
+const macShortcuts: ShortcutMap = {
+ isTokenModifier: (e) => e.metaKey,
+ isFieldModifier: (e) => e.altKey,
+ isApplyModifier: (e) => e.metaKey,
+ isUndo: (e) => e.metaKey && !e.shiftKey && e.code === 'KeyZ',
+ isRedo: (e) => e.metaKey && e.shiftKey && e.code === 'KeyZ',
+};
+
+const winShortcuts: ShortcutMap = {
+ isTokenModifier: (e) => e.ctrlKey && e.altKey,
+ isFieldModifier: (e) => e.ctrlKey && !e.altKey,
+ isApplyModifier: (e) => e.ctrlKey,
+ isUndo: (e) => e.ctrlKey && !e.shiftKey && e.code === 'KeyZ',
+ isRedo: (e) =>
+ (e.ctrlKey && e.code === 'KeyY') || (e.ctrlKey && e.shiftKey && e.code === 'KeyZ'),
+};
+
+const isMac = () => {
+ if (typeof window === 'undefined') {
+ return false;
+ }
+ return navigator.userAgent.toUpperCase().indexOf('MAC') >= 0;
+};
+
+const shortcuts = isMac() ? macShortcuts : winShortcuts;
+
+export const useKeyDownHandler = () => {
+ const {focusInfo, inputInfo, options} = useTokenizedInput();
+
+ const {fields, tokens} = inputInfo.state;
+ const {onRemoveToken, onChangeToken, onUndo, onRedo, onApplyChanges} = inputInfo.callbacks;
+ const {focus} = focusInfo.state;
+ const {getFocusRules, onFocus, onBlur} = focusInfo.callbacks;
+ const {onKeyDown} = options;
+
+ const getCursorOffset = React.useCallback((input: HTMLInputElement) => {
+ if (!input.value || input.readOnly) {
+ return undefined;
+ }
+ return input.selectionStart === input.value.length ? -1 : (input.selectionStart ?? 0);
+ }, []);
+
+ const reservedKeys = React.useMemo(() => {
+ return fields.flatMap(
+ ({key, specialKeysActions}) =>
+ specialKeysActions?.map((action) => ({
+ ...action,
+ fieldKey: key,
+ })) ?? [],
+ );
+ }, [fields]);
+
+ const checkKey = React.useCallback(
+ (e: React.KeyboardEvent, key: string) => {
+ if (
+ e.key === key &&
+ !reservedKeys.some((reserved) => {
+ let isReservedKey = false;
+ if (typeof reserved.key === 'string') {
+ isReservedKey = reserved.key === e.key;
+ } else {
+ isReservedKey = reserved.key(e);
+ }
+
+ return isReservedKey && focus?.key === reserved.fieldKey;
+ })
+ ) {
+ return true;
+ }
+ return false;
+ },
+ [focus, reservedKeys],
+ );
+
+ // move to next field at end of word
+ // move to previous field at start of word
+ const moveToNeighborField = React.useCallback(
+ (e: React.KeyboardEvent) => {
+ if (!focus) {
+ return false;
+ }
+
+ const input = e.target as HTMLInputElement;
+ const focusRules = getFocusRules({
+ ...focus,
+ offset: undefined,
+ });
+
+ const token = tokens[focus.idx];
+ const isReadOnlyField = token?.options?.readOnlyFields?.includes(focus.key);
+
+ if (
+ token &&
+ (input.selectionStart === input.value.length || isReadOnlyField) &&
+ checkKey(e, KeyCode.ArrowRight)
+ ) {
+ e.preventDefault();
+ onFocus({
+ ...focusRules.nextField,
+ offset: 0,
+ });
+
+ return true;
+ }
+
+ if (
+ (focusRules.prevField.key !== focus.key ||
+ focusRules.prevField.idx !== focus.idx) &&
+ (input.selectionStart === 0 || isReadOnlyField) &&
+ checkKey(e, KeyCode.ArrowLeft)
+ ) {
+ e.preventDefault();
+ onFocus({
+ ...focusRules.prevField,
+ offset: -1,
+ });
+
+ return true;
+ }
+
+ return false;
+ },
+ [checkKey, focus, getFocusRules, onFocus, tokens],
+ );
+
+ const tabJumping = React.useCallback(
+ (e: React.KeyboardEvent) => {
+ if (!focus) {
+ return false;
+ }
+
+ const focusRules = getFocusRules({
+ ...focus,
+ offset: undefined,
+ });
+
+ if (checkKey(e, KeyCode.Tab)) {
+ if (
+ e.shiftKey &&
+ ((focus.idx === 0 && fields[0].key !== focus.key) || focus.idx > 0)
+ ) {
+ e.preventDefault();
+ onFocus(focusRules.prevField);
+
+ return true;
+ }
+
+ if (!e.shiftKey && tokens[focus.idx]?.value) {
+ e.preventDefault();
+ onFocus(focusRules.nextField);
+
+ return true;
+ }
+ }
+
+ return false;
+ },
+ [checkKey, fields, focus, getFocusRules, onFocus, tokens],
+ );
+
+ // move to next/previous field
+ const jumpToNeighborField = React.useCallback(
+ (e: React.KeyboardEvent) => {
+ if (!focus) {
+ return false;
+ }
+
+ const input = e.target as HTMLInputElement;
+ const focusRules = getFocusRules({
+ ...focus,
+ offset: getCursorOffset(input),
+ });
+
+ if (checkKey(e, KeyCode.ArrowRight)) {
+ e.preventDefault();
+ onFocus(focusRules.nextField);
+
+ return true;
+ }
+ if (checkKey(e, KeyCode.ArrowLeft)) {
+ e.preventDefault();
+ onFocus(focusRules.prevField);
+
+ return true;
+ }
+
+ return false;
+ },
+ [checkKey, focus, getCursorOffset, getFocusRules, onFocus],
+ );
+
+ // move to next/previous token
+ const jumpToNeighborToken = React.useCallback(
+ (e: React.KeyboardEvent) => {
+ if (!focus) {
+ return false;
+ }
+
+ const input = e.target as HTMLInputElement;
+ const focusRules = getFocusRules({
+ ...focus,
+ offset: getCursorOffset(input),
+ });
+
+ if (checkKey(e, KeyCode.ArrowRight)) {
+ e.preventDefault();
+ onFocus(focusRules.nextToken);
+
+ return true;
+ }
+ if (checkKey(e, KeyCode.ArrowLeft)) {
+ e.preventDefault();
+ onFocus(focusRules.prevToken);
+
+ return true;
+ }
+
+ return false;
+ },
+ [checkKey, focus, getCursorOffset, getFocusRules, onFocus],
+ );
+
+ const navigationHandler = React.useCallback(
+ (e: React.KeyboardEvent) => {
+ if (!e.shiftKey) {
+ if (shortcuts.isTokenModifier(e)) {
+ return jumpToNeighborToken(e);
+ }
+ if (shortcuts.isFieldModifier(e)) {
+ return jumpToNeighborField(e);
+ }
+ if (!checkKey(e, KeyCode.Tab)) {
+ return moveToNeighborField(e);
+ }
+ }
+ return tabJumping(e);
+ },
+ [checkKey, jumpToNeighborField, jumpToNeighborToken, moveToNeighborField, tabJumping],
+ );
+
+ const deleteHandler = React.useCallback(
+ (e: React.KeyboardEvent) => {
+ const input = e.target as HTMLInputElement;
+
+ if (!focus || !input) {
+ return false;
+ }
+
+ if (checkKey(e, KeyCode.Backspace)) {
+ if (shortcuts.isTokenModifier(e)) {
+ e.preventDefault();
+
+ let idx = focus.idx;
+
+ if (!tokens[idx]) {
+ idx--;
+ }
+
+ if (idx < 0 || tokens[idx].options?.notRemovable) {
+ return false;
+ }
+
+ e.preventDefault();
+ onRemoveToken(idx);
+ onFocus({
+ idx,
+ key: fields[0].key,
+ });
+
+ return true;
+ }
+ if (input.selectionStart === 0 && input.selectionEnd === 0) {
+ const {prevField} = getFocusRules({
+ ...focus,
+ offset: 0,
+ });
+
+ const {idx, key} = prevField;
+
+ if (
+ (focus.key === key && focus.idx === idx) ||
+ tokens[idx].options?.readOnlyFields?.includes(key)
+ ) {
+ return false;
+ }
+
+ e.preventDefault();
+ if (idx === focus.idx) {
+ onChangeToken(idx, {
+ [key]: tokens[idx].value[key].slice(0, -1),
+ });
+ }
+
+ onFocus({...prevField, offset: -1});
+
+ return true;
+ }
+ }
+
+ return false;
+ },
+ [checkKey, fields, focus, getFocusRules, onChangeToken, onFocus, onRemoveToken, tokens],
+ );
+
+ const blurHandler = React.useCallback(
+ (e: React.KeyboardEvent) => {
+ const handler = () => {
+ const input = e.target as HTMLInputElement;
+ input.blur();
+ onBlur();
+ };
+
+ if (checkKey(e, KeyCode.Enter)) {
+ e.preventDefault();
+ onApplyChanges();
+
+ if (shortcuts.isApplyModifier(e)) {
+ handler();
+
+ return true;
+ } else {
+ if (!focus) {
+ return false;
+ }
+
+ const input = e.target as HTMLInputElement;
+ const focusRules = getFocusRules({
+ ...focus,
+ offset: getCursorOffset(input),
+ });
+
+ onFocus(focusRules.nextToken);
+
+ return true;
+ }
+ }
+ if (checkKey(e, KeyCode.Escape)) {
+ e.preventDefault();
+ handler();
+
+ return true;
+ }
+
+ return false;
+ },
+ [checkKey, focus, getCursorOffset, getFocusRules, onApplyChanges, onBlur, onFocus],
+ );
+
+ const specialKeysActionsHandler = React.useCallback(
+ (e: React.KeyboardEvent) => {
+ const input = e.target as HTMLInputElement;
+
+ if (!focus || !input) {
+ return false;
+ }
+
+ const field = fields.find(({key}) => key === focus?.key);
+ const action = field?.specialKeysActions?.find(({key}) => {
+ if (typeof key === 'string') {
+ return key === e.key;
+ }
+ return key(e);
+ })?.action;
+
+ if (!action) {
+ return false;
+ }
+
+ const token = tokens[focus.idx] ?? {
+ id: `tokenNew${tokens.length}`,
+ isNew: true,
+ value: {},
+ };
+
+ action({
+ token,
+ offset: input.selectionStart ?? 0,
+ focus,
+ onFocus,
+ onChange: onChangeToken,
+ onApply: onApplyChanges,
+ event: e,
+ });
+
+ return true;
+ },
+ [fields, focus, onApplyChanges, onChangeToken, onFocus, tokens],
+ );
+
+ const undoRedo = React.useCallback(
+ (e: React.KeyboardEvent) => {
+ if (!focus) {
+ return false;
+ }
+
+ const focusLastToken = (newTokens: Token[]) => {
+ const idx = newTokens.findIndex((t) => t.isNew);
+
+ onFocus({
+ idx: idx === -1 ? newTokens.length : idx,
+ key: fields[0].key,
+ ignoreChecks: true,
+ });
+ };
+
+ if (shortcuts.isRedo(e)) {
+ e.preventDefault();
+ const newTokens = onRedo();
+ focusLastToken(newTokens);
+
+ return true;
+ }
+ if (shortcuts.isUndo(e)) {
+ e.preventDefault();
+ const newTokens = onUndo();
+ focusLastToken(newTokens);
+
+ return true;
+ }
+
+ return false;
+ },
+ [fields, focus, onFocus, onRedo, onUndo],
+ );
+
+ const externalKeyDown = React.useCallback(
+ (e: React.KeyboardEvent) => {
+ const input = e.target as HTMLInputElement;
+
+ if (!focus || !input) {
+ return false;
+ }
+
+ const token = tokens[focus.idx] ?? {
+ id: `tokenNew${tokens.length}`,
+ isNew: true,
+ value: {},
+ };
+
+ return (
+ onKeyDown?.({
+ token,
+ offset: input.selectionStart ?? 0,
+ focus,
+ onFocus,
+ onChange: onChangeToken,
+ onApply: onApplyChanges,
+ event: e,
+ }) ?? false
+ );
+ },
+ [focus, onApplyChanges, onChangeToken, onFocus, onKeyDown, tokens],
+ );
+
+ return React.useCallback(
+ (e: React.KeyboardEvent) => {
+ switch (true) {
+ case externalKeyDown(e): {
+ break;
+ }
+ case specialKeysActionsHandler(e): {
+ break;
+ }
+ case navigationHandler(e): {
+ break;
+ }
+ case deleteHandler(e): {
+ break;
+ }
+ case blurHandler(e): {
+ break;
+ }
+ case undoRedo(e): {
+ break;
+ }
+ }
+ },
+ [
+ blurHandler,
+ deleteHandler,
+ externalKeyDown,
+ navigationHandler,
+ specialKeysActionsHandler,
+ undoRedo,
+ ],
+ );
+};
diff --git a/src/components/TokenizedInput/components/Wrapper/hooks/useWrapper.ts b/src/components/TokenizedInput/components/Wrapper/hooks/useWrapper.ts
new file mode 100644
index 00000000..31caff23
--- /dev/null
+++ b/src/components/TokenizedInput/components/Wrapper/hooks/useWrapper.ts
@@ -0,0 +1,52 @@
+import * as React from 'react';
+
+import {b} from '../../../constants';
+import {useTokenizedInput} from '../../../context';
+import {useApplyCallbackOnBlur} from '../../../hooks';
+
+import {useKeyDownHandler} from './useKeyDownHandler';
+
+export const useWrapper = () => {
+ const {focusInfo, inputInfo} = useTokenizedInput();
+
+ const {tokens, fields, isEditable, isClearable, className, wrapperRef} = inputInfo.state;
+ const {onApplyChanges, onClearInput} = inputInfo.callbacks;
+
+ const {focus} = focusInfo.state;
+ const {onBlur, onFocus} = focusInfo.callbacks;
+
+ const blurCallback = React.useCallback(() => {
+ onBlur();
+ onApplyChanges();
+ }, [onApplyChanges, onBlur]);
+
+ const handleBlur = useApplyCallbackOnBlur(blurCallback);
+ const handleKeyDown = useKeyDownHandler();
+ const handleClear = React.useCallback(() => {
+ onClearInput();
+ onFocus({
+ idx: tokens.length,
+ key: fields[0].key,
+ });
+ }, [fields, onClearInput, onFocus, tokens.length]);
+
+ const classNames = React.useMemo(
+ () => ({
+ wrapper: b('wrapper', {disabled: !isEditable, focused: Boolean(focus)}, className),
+ clearButton: b('clear-button'),
+ }),
+ [className, focus, isEditable],
+ );
+
+ return React.useMemo(
+ () => ({
+ state: {isEditable, isClearable, classNames, wrapperRef},
+ callbacks: {
+ onBlur: handleBlur,
+ onKeyDown: handleKeyDown,
+ onClear: handleClear,
+ },
+ }),
+ [classNames, handleBlur, handleClear, handleKeyDown, isClearable, isEditable, wrapperRef],
+ );
+};
diff --git a/src/components/TokenizedInput/components/Wrapper/index.ts b/src/components/TokenizedInput/components/Wrapper/index.ts
new file mode 100644
index 00000000..7b86c3fc
--- /dev/null
+++ b/src/components/TokenizedInput/components/Wrapper/index.ts
@@ -0,0 +1,2 @@
+export {Wrapper as WrapperComponent} from './Wrapper';
+export {useWrapper as useTokenizedInputWrapper} from './hooks';
diff --git a/src/components/TokenizedInput/components/index.ts b/src/components/TokenizedInput/components/index.ts
new file mode 100644
index 00000000..b3cd3df3
--- /dev/null
+++ b/src/components/TokenizedInput/components/index.ts
@@ -0,0 +1,4 @@
+export * from './Wrapper';
+export * from './Tokens';
+export * from './Field';
+export * from './Suggestions';
diff --git a/src/components/TokenizedInput/constants.ts b/src/components/TokenizedInput/constants.ts
new file mode 100644
index 00000000..2a4adab9
--- /dev/null
+++ b/src/components/TokenizedInput/constants.ts
@@ -0,0 +1,20 @@
+import {block} from '../utils/cn';
+
+export const b = block('tokenized-input');
+
+export enum KeyCode {
+ Tab = 'Tab',
+ Enter = 'Enter',
+ NumpadEnter = 'NumpadEnter',
+ ArrowUp = 'ArrowUp',
+ ArrowLeft = 'ArrowLeft',
+ ArrowDown = 'ArrowDown',
+ ArrowRight = 'ArrowRight',
+ PageUp = 'PageUp',
+ PageDown = 'PageDown',
+ Home = 'Home',
+ End = 'End',
+ Space = ' ',
+ Backspace = 'Backspace',
+ Escape = 'Escape',
+}
diff --git a/src/components/TokenizedInput/context/TokenizedInputComponentsContext.tsx b/src/components/TokenizedInput/context/TokenizedInputComponentsContext.tsx
new file mode 100644
index 00000000..f1826957
--- /dev/null
+++ b/src/components/TokenizedInput/context/TokenizedInputComponentsContext.tsx
@@ -0,0 +1,54 @@
+import * as React from 'react';
+
+import {
+ FieldComponent,
+ SuggestionsComponent,
+ TokenComponent,
+ TokenListComponent,
+ WrapperComponent,
+} from '../components';
+import type {TokenizedInputComposition} from '../types';
+
+const TokenizedInputComponentsContext = React.createContext({
+ Wrapper: WrapperComponent,
+ TokenList: TokenListComponent,
+ Token: TokenComponent,
+ Field: FieldComponent,
+ Suggestions: SuggestionsComponent,
+});
+
+export function TokenizedInputComponentContextProvider({
+ Wrapper,
+ TokenList,
+ Token,
+ Field,
+ Suggestions,
+ children,
+}: React.PropsWithChildren) {
+ const ctxValue = React.useMemo(
+ () => ({
+ Wrapper,
+ TokenList,
+ Token,
+ Field,
+ Suggestions,
+ }),
+ [Field, Suggestions, Token, TokenList, Wrapper],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+export const useTokenizedInputComponents = () => {
+ const ctx = React.useContext(TokenizedInputComponentsContext);
+
+ if (!ctx) {
+ throw new Error('TokenizedInput context is not defined');
+ }
+
+ return ctx;
+};
diff --git a/src/components/TokenizedInput/context/TokenizedInputContext.tsx b/src/components/TokenizedInput/context/TokenizedInputContext.tsx
new file mode 100644
index 00000000..2d59f937
--- /dev/null
+++ b/src/components/TokenizedInput/context/TokenizedInputContext.tsx
@@ -0,0 +1,170 @@
+import * as React from 'react';
+
+import {useSuggestionsInitialCall, useTokenizedInputFocus, useTokenizedInputInfo} from '../hooks';
+import {useTokenizedInputComponentFocus} from '../hooks/useTokenizedInputComponentFocus';
+import type {
+ TokenValueBase,
+ TokenizedInputData,
+ TokenizedInputFocusInfo,
+ TokenizedInputInfo,
+} from '../types';
+
+type TokenizedInputContextOptions = {
+ /** Input state and callbacks */
+ inputInfo: TokenizedInputInfo;
+ /** Focus state and callbacks */
+ focusInfo: TokenizedInputFocusInfo;
+ /** Extra options from props */
+ options: {
+ /** Suggestions fetcher */
+ onSuggest: TokenizedInputData['onSuggest'];
+ /** Keydown handler; return true to stop further handling */
+ onKeyDown: TokenizedInputData['onKeyDown'];
+ /** Suggestions debounce delay */
+ debounceDelay: number | Record;
+ /** First suggestions call: ensures the first focus triggers a request without debounce */
+ suggestionsInitialCall: {
+ value: React.MutableRefObject;
+ setValue: (v: boolean) => void;
+ };
+ /** Render suggestions full width below the input */
+ fullWidthSuggestions: boolean;
+ /** Return true to allow blur, false to prevent it */
+ shouldAllowBlur?: (e: React.FocusEvent) => boolean;
+ };
+};
+
+const TokenizedInputContext = React.createContext>({
+ inputInfo: {
+ state: {
+ tokens: [],
+ wrapperRef: {current: null},
+ fields: [],
+ isEditable: true,
+ isClearable: true,
+ },
+ callbacks: {
+ onApplyChanges: () => undefined,
+ onChangeToken: () => [],
+ onChangeTokens: () => [],
+ onRemoveToken: () => [],
+ onClearInput: () => [],
+ onUndo: () => [],
+ onRedo: () => [],
+ },
+ },
+ focusInfo: {
+ state: {focus: undefined, autoFocus: false},
+ callbacks: {
+ onFocus: () => undefined,
+ onBlur: () => undefined,
+ getFocusRules: () => ({
+ nextField: {idx: 0, key: ''},
+ prevField: {idx: 0, key: ''},
+ nextToken: {idx: 0, key: ''},
+ prevToken: {idx: 0, key: ''},
+ }),
+ },
+ },
+ options: {
+ onSuggest: () => ({
+ items: [],
+ }),
+ onKeyDown: () => false,
+ debounceDelay: 150,
+ suggestionsInitialCall: {value: {current: true}, setValue: () => undefined},
+ fullWidthSuggestions: false,
+ shouldAllowBlur: () => true,
+ },
+});
+
+export function TokenizedInputContextProvider({
+ debounceDelay = 150,
+ debounceFlushStrategy = 'focus-field',
+ autoFocus = false,
+ fullWidthSuggestions = false,
+ tokens,
+ defaultTokens,
+ transformTokens,
+ validateToken,
+ formatToken,
+ fields,
+ placeholder,
+ className,
+ isEditable,
+ isClearable,
+ onKeyDown,
+ onChange,
+ onSuggest,
+ onFocus,
+ onBlur,
+ shouldAllowBlur = () => true,
+ children,
+}: React.PropsWithChildren>) {
+ const inputInfo = useTokenizedInputInfo({
+ tokens,
+ defaultTokens,
+ transformTokens,
+ validateToken,
+ formatToken,
+ fields,
+ placeholder,
+ className,
+ isEditable,
+ isClearable,
+ onChange,
+ });
+ const focusInfo = useTokenizedInputFocus({fields, inputInfo, autoFocus});
+ const suggestionsInitialCall = useSuggestionsInitialCall(
+ focusInfo.state.focus,
+ debounceFlushStrategy,
+ );
+
+ useTokenizedInputComponentFocus({
+ focusInfo,
+ onBlur,
+ onFocus,
+ });
+
+ const ctxValue = React.useMemo(
+ () =>
+ ({
+ inputInfo,
+ focusInfo,
+ options: {
+ onSuggest,
+ onKeyDown,
+ debounceDelay,
+ suggestionsInitialCall,
+ fullWidthSuggestions,
+ shouldAllowBlur,
+ },
+ }) as unknown as TokenizedInputContextOptions,
+ [
+ debounceDelay,
+ shouldAllowBlur,
+ focusInfo,
+ fullWidthSuggestions,
+ inputInfo,
+ onKeyDown,
+ onSuggest,
+ suggestionsInitialCall,
+ ],
+ );
+
+ return (
+ {children}
+ );
+}
+
+export const useTokenizedInput = () => {
+ const ctx = React.useContext(
+ TokenizedInputContext as unknown as React.Context>,
+ );
+
+ if (!ctx) {
+ throw new Error('TokenizedInput context is not defined');
+ }
+
+ return ctx;
+};
diff --git a/src/components/TokenizedInput/context/index.ts b/src/components/TokenizedInput/context/index.ts
new file mode 100644
index 00000000..0ac4fe67
--- /dev/null
+++ b/src/components/TokenizedInput/context/index.ts
@@ -0,0 +1,2 @@
+export * from './TokenizedInputComponentsContext';
+export * from './TokenizedInputContext';
diff --git a/src/components/TokenizedInput/hooks/index.ts b/src/components/TokenizedInput/hooks/index.ts
new file mode 100644
index 00000000..dae550b3
--- /dev/null
+++ b/src/components/TokenizedInput/hooks/index.ts
@@ -0,0 +1,4 @@
+export {useApplyCallbackOnBlur} from './useApplyCallbackOnBlur';
+export {useSuggestionsInitialCall} from './useSuggestionsInitialCall';
+export {useTokenizedInputFocus} from './useTokenizedInputFocus';
+export {useTokenizedInputInfo} from './useTokenizedInputInfo';
diff --git a/src/components/TokenizedInput/hooks/useApplyCallbackOnBlur.ts b/src/components/TokenizedInput/hooks/useApplyCallbackOnBlur.ts
new file mode 100644
index 00000000..59c7c2e1
--- /dev/null
+++ b/src/components/TokenizedInput/hooks/useApplyCallbackOnBlur.ts
@@ -0,0 +1,17 @@
+import * as React from 'react';
+
+import {useTokenizedInput} from '../context';
+
+export const useApplyCallbackOnBlur = (fn: (e: React.FocusEvent) => void) => {
+ const {
+ options: {shouldAllowBlur},
+ } = useTokenizedInput();
+ return React.useCallback(
+ (e: React.FocusEvent) => {
+ if (!e.currentTarget.contains(e.relatedTarget) && shouldAllowBlur?.(e)) {
+ fn(e);
+ }
+ },
+ [fn, shouldAllowBlur],
+ );
+};
diff --git a/src/components/TokenizedInput/hooks/useSuggestionsInitialCall.ts b/src/components/TokenizedInput/hooks/useSuggestionsInitialCall.ts
new file mode 100644
index 00000000..7e34717a
--- /dev/null
+++ b/src/components/TokenizedInput/hooks/useSuggestionsInitialCall.ts
@@ -0,0 +1,31 @@
+import * as React from 'react';
+
+import type {TokenFocus, TokenValueBase, TokenizedInputData} from '../types';
+
+export const useSuggestionsInitialCall = (
+ focus: TokenFocus | undefined,
+ debounceFlushStrategy: TokenizedInputData['debounceFlushStrategy'],
+) => {
+ const initialCallRef = React.useRef(true);
+
+ React.useEffect(() => {
+ if (debounceFlushStrategy === 'focus-input') {
+ initialCallRef.current = !focus;
+ } else {
+ initialCallRef.current = true;
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [focus?.key, focus?.idx]);
+
+ const setInitialCall = React.useCallback((value: boolean) => {
+ initialCallRef.current = value;
+ }, []);
+
+ return React.useMemo(
+ () => ({
+ value: initialCallRef,
+ setValue: setInitialCall,
+ }),
+ [setInitialCall],
+ );
+};
diff --git a/src/components/TokenizedInput/hooks/useTokenizedInputComponentFocus.ts b/src/components/TokenizedInput/hooks/useTokenizedInputComponentFocus.ts
new file mode 100644
index 00000000..fc614fff
--- /dev/null
+++ b/src/components/TokenizedInput/hooks/useTokenizedInputComponentFocus.ts
@@ -0,0 +1,33 @@
+import * as React from 'react';
+
+import {TokenValueBase, TokenizedInputFocusInfo} from '../types';
+
+type Props = {
+ focusInfo: TokenizedInputFocusInfo;
+ onFocus?: () => void;
+ onBlur?: () => void;
+};
+
+export const useTokenizedInputComponentFocus = ({
+ focusInfo,
+ onFocus,
+ onBlur,
+}: Props) => {
+ const lastFocused = React.useRef(null);
+
+ React.useEffect(() => {
+ const p = lastFocused.current;
+ const n = focusInfo.state.focus?.idx;
+
+ const pEmpty = !p && p !== 0;
+ const nEmpty = !n && n !== 0;
+
+ if (pEmpty && !nEmpty) {
+ onFocus?.();
+ } else if (!pEmpty && nEmpty) {
+ onBlur?.();
+ }
+
+ lastFocused.current = n ?? null;
+ }, [focusInfo.state.focus?.idx, onBlur, onFocus]);
+};
diff --git a/src/components/TokenizedInput/hooks/useTokenizedInputFocus.ts b/src/components/TokenizedInput/hooks/useTokenizedInputFocus.ts
new file mode 100644
index 00000000..11b4e893
--- /dev/null
+++ b/src/components/TokenizedInput/hooks/useTokenizedInputFocus.ts
@@ -0,0 +1,194 @@
+import * as React from 'react';
+
+import type {
+ Token,
+ TokenField,
+ TokenFocus,
+ TokenValueBase,
+ TokenizedInputData,
+ TokenizedInputFocusInfo,
+ TokenizedInputInfo,
+} from '../types';
+
+type UseTokenizedInputFocusOptions = Pick<
+ TokenizedInputData,
+ 'fields' | 'autoFocus'
+> & {
+ inputInfo: TokenizedInputInfo;
+};
+
+const getInitialFocus = (
+ tokens: Token[],
+ fields: TokenField[],
+ autoFocus?: boolean,
+) => {
+ if (!autoFocus) {
+ return undefined;
+ }
+
+ const newTokenIdx = tokens.findIndex((t) => t.isNew);
+
+ return {idx: newTokenIdx === -1 ? tokens.length : newTokenIdx, key: fields[0].key, offset: -1};
+};
+
+export const useTokenizedInputFocus = ({
+ fields,
+ inputInfo,
+ autoFocus,
+}: UseTokenizedInputFocusOptions): TokenizedInputFocusInfo => {
+ const {tokens} = inputInfo.state;
+ const {onApplyChanges} = inputInfo.callbacks;
+
+ const [isAutoFocused, setIsAutoFocused] = React.useState(!autoFocus);
+
+ React.useEffect(() => {
+ setIsAutoFocused(true);
+ }, []);
+
+ const [focus, setFocus] = React.useState | undefined>(
+ getInitialFocus(tokens, fields, autoFocus),
+ );
+
+ const onFocus = React.useCallback(
+ (newFocus: TokenFocus) => {
+ const {idx, key, offset, ignoreChecks} = newFocus;
+
+ const isNewToken =
+ (idx === tokens.length && tokens[idx - 1]?.isNew) ||
+ (idx === tokens.length + 1 && !tokens.at(-1)?.isNew);
+
+ // new token is being finalized and not all fields are empty
+ if (isNewToken) {
+ const hasNonEmptyFields =
+ Object.values(tokens.find((t) => t.isNew)?.value ?? {}).some(Boolean) ||
+ ignoreChecks;
+
+ if (hasNonEmptyFields) {
+ onApplyChanges();
+ setFocus({
+ idx,
+ key: fields[0].key,
+ offset,
+ });
+ }
+ return;
+ }
+
+ // handle focus past the end of the list
+ if (idx - tokens.length > 0) {
+ setFocus({
+ idx: tokens.length,
+ key: fields[0].key,
+ offset,
+ });
+ return;
+ }
+
+ setFocus((cur) => {
+ // !cur — initial focus; ignoreChecks — skip boundary checks
+ if (!cur || ignoreChecks) {
+ return newFocus;
+ }
+ // existing (non-new) tokens: no checks needed
+ if (tokens[cur.idx] && !tokens[cur.idx].isNew) {
+ return newFocus;
+ }
+
+ // new tokens
+ const curKeyIndex = fields.findIndex((f) => f.key === cur.key);
+ const keyIndex = fields.findIndex((f) => f.key === key);
+ const curValuesNonEmptyCondition = fields
+ .slice(0, keyIndex)
+ .some((f) => !tokens[cur.idx]?.value?.[f.key]);
+ const allValuesNonEmptyCondition = fields.some(
+ (f) => !tokens[cur.idx]?.value?.[f.key],
+ );
+
+ const curEmptyFieldCondition =
+ idx === cur.idx && curKeyIndex < keyIndex && curValuesNonEmptyCondition;
+ const nextEmptyFieldCondition =
+ idx > cur.idx &&
+ curKeyIndex === fields.length - 1 &&
+ !allValuesNonEmptyCondition;
+
+ // empty fields
+ if (curEmptyFieldCondition || nextEmptyFieldCondition) {
+ return {...cur, offset};
+ }
+
+ return newFocus;
+ });
+ },
+ [fields, onApplyChanges, tokens],
+ );
+
+ const onBlur = React.useCallback(() => {
+ setFocus(undefined);
+ }, []);
+
+ const getFocusRules = React.useCallback(
+ (value: TokenFocus) => {
+ const {idx, key, offset} = value;
+
+ const keyIndex = fields.findIndex((f) => f.key === key);
+ const noOffset = offset === undefined;
+
+ const prevField: TokenFocus = {
+ key: noOffset || offset === 0 ? fields[keyIndex - 1]?.key : key,
+ idx,
+ offset: 0,
+ };
+ const nextField: TokenFocus = {
+ key: noOffset || offset === -1 ? fields[keyIndex + 1]?.key : key,
+ idx,
+ offset: -1,
+ };
+
+ if (!prevField.key) {
+ prevField.key = prevField.idx === 0 ? key : (fields.at(-1)?.key ?? key);
+ prevField.idx = prevField.idx === 0 ? 0 : prevField.idx - 1;
+ }
+
+ if (!nextField.key) {
+ nextField.key = fields[0].key;
+ nextField.idx++;
+ }
+
+ const prevToken: TokenFocus = {
+ key: fields[0].key,
+ idx: idx === 0 || key !== fields[0].key ? idx : idx - 1,
+ offset: 0,
+ };
+
+ const nextToken: TokenFocus = {
+ key: fields.at(-1)?.key ?? key,
+ idx: key === fields.at(-1)?.key ? idx + 1 : idx,
+ offset: -1,
+ };
+
+ if (nextToken.idx === tokens.length || tokens[nextToken.idx]?.isNew) {
+ nextToken.key = fields[0].key;
+ }
+
+ return {
+ nextField,
+ prevField,
+ nextToken,
+ prevToken,
+ };
+ },
+ [fields, tokens],
+ );
+
+ return React.useMemo(
+ () => ({
+ state: {focus, autoFocus: isAutoFocused ? false : autoFocus},
+ callbacks: {
+ onFocus,
+ onBlur,
+ getFocusRules,
+ },
+ }),
+ [autoFocus, focus, getFocusRules, isAutoFocused, onBlur, onFocus],
+ );
+};
diff --git a/src/components/TokenizedInput/hooks/useTokenizedInputInfo.ts b/src/components/TokenizedInput/hooks/useTokenizedInputInfo.ts
new file mode 100644
index 00000000..2feece8e
--- /dev/null
+++ b/src/components/TokenizedInput/hooks/useTokenizedInputInfo.ts
@@ -0,0 +1,219 @@
+import * as React from 'react';
+
+import {getUniqId} from '@gravity-ui/uikit';
+import isEqual from 'lodash/isEqual';
+
+import type {Token, TokenValueBase, TokenizedInputData, TokenizedInputInfo} from '../types';
+import {UndoRedoManager} from '../undoredo-manager';
+import {
+ defaultTransformTokens,
+ defaultValidateToken,
+ getDefaultTokenValue,
+ getValuesFromTokens,
+ removeEmptyTokens,
+} from '../utils';
+
+type UseTokenizedInputInfoOptions = Omit<
+ TokenizedInputData,
+ 'onSuggest' | 'onKeyDown' | 'debounceDelay' | 'autoFocus'
+>;
+
+export const useTokenizedInputInfo = ({
+ defaultTokens = [],
+ isEditable = true,
+ isClearable = true,
+ transformTokens = defaultTransformTokens,
+ validateToken = defaultValidateToken,
+ formatToken,
+ tokens: externalTokens,
+ fields,
+ placeholder,
+ className,
+ onChange,
+}: UseTokenizedInputInfoOptions): TokenizedInputInfo => {
+ const validateTokens = React.useCallback(
+ (t: Token[]): Token[] =>
+ t.map((token) => ({
+ ...token,
+ errors: validateToken ? validateToken(token.value) : undefined,
+ })),
+ [validateToken],
+ );
+
+ const [tokens, setTokens] = React.useState(validateTokens(transformTokens(externalTokens)));
+
+ const wrapperRef = React.useRef(null);
+ const tokensRef = React.useRef(tokens);
+ const undoRedoManager = React.useRef(
+ new UndoRedoManager(validateTokens(transformTokens(externalTokens))),
+ );
+
+ React.useEffect(() => {
+ if (!isEqual(getValuesFromTokens(tokens.filter((t) => !t.isNew)), externalTokens)) {
+ const newTokens = validateTokens(transformTokens(externalTokens));
+
+ tokensRef.current = newTokens;
+ setTokens(newTokens);
+ undoRedoManager.current.init(newTokens);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [externalTokens]);
+
+ const onChangeTokens = React.useCallback(
+ (newTokens: Token[], forceRewriteHistory = false) => {
+ tokensRef.current = newTokens;
+ setTokens(newTokens);
+ undoRedoManager.current.update(newTokens, forceRewriteHistory);
+
+ return newTokens;
+ },
+ [],
+ );
+
+ const onChangeToken = React.useCallback(
+ (idx: number, newValue: Partial) => {
+ const prevTokens = tokensRef.current;
+
+ if (idx >= prevTokens.length) {
+ const newTokens = [
+ ...prevTokens,
+ {
+ id: `tokenId${getUniqId()}`,
+ isNew: true,
+ value: {
+ ...getDefaultTokenValue(fields),
+ ...newValue,
+ },
+ },
+ ];
+
+ return onChangeTokens(newTokens);
+ } else {
+ const newTokens = prevTokens
+ .map((t, i) =>
+ i === idx
+ ? {
+ ...t,
+ value: {...t.value, ...newValue},
+ errors: undefined,
+ }
+ : {...t},
+ )
+ .filter((t) => Object.values(t.value).some((v) => v.trim()));
+
+ return onChangeTokens(newTokens);
+ }
+ },
+ [fields, onChangeTokens],
+ );
+
+ const onApplyChanges = React.useCallback(
+ (currentTokens = false) => {
+ const transformedTokens = tokensRef.current.map((t) => {
+ const {value, options} = transformTokens([t.value])[0];
+
+ return {...t, value, options};
+ });
+ const newTokens = removeEmptyTokens(transformedTokens)
+ .map((t) => {
+ // apply changes only to existing tokens (not the in-progress new token)
+ if (currentTokens && t.isNew) {
+ return undefined;
+ }
+ const formattedValue = formatToken?.(t.value) ?? t.value;
+
+ return {
+ ...t,
+ isNew: false,
+ value: formattedValue,
+ errors: validateToken ? validateToken(formattedValue) : undefined,
+ };
+ })
+ .filter(Boolean) as Token[];
+
+ onChange(getValuesFromTokens(newTokens));
+ onChangeTokens(newTokens, true);
+ },
+ [formatToken, onChange, onChangeTokens, transformTokens, validateToken],
+ );
+
+ const onRemoveToken = React.useCallback(
+ (idx: number) => {
+ const newTokens = tokensRef.current.filter((_, i) => i !== idx);
+ onChange(getValuesFromTokens(newTokens));
+ return onChangeTokens(newTokens);
+ },
+ [onChange, onChangeTokens],
+ );
+
+ const onClearInput = React.useCallback(() => {
+ const newTokens = transformTokens(defaultTokens);
+ onChange(defaultTokens);
+ return onChangeTokens(newTokens);
+ }, [defaultTokens, onChange, onChangeTokens, transformTokens]);
+
+ const onUndo = React.useCallback(() => {
+ const newTokens = undoRedoManager.current.undo();
+
+ tokensRef.current = newTokens;
+ setTokens(newTokens);
+ onChange(getValuesFromTokens(removeEmptyTokens(newTokens.filter((t) => !t.isNew))));
+
+ return newTokens;
+ }, [onChange]);
+
+ const onRedo = React.useCallback(() => {
+ const newTokens = undoRedoManager.current.redo();
+
+ tokensRef.current = newTokens;
+ setTokens(newTokens);
+ onChange(getValuesFromTokens(removeEmptyTokens(newTokens.filter((t) => !t.isNew))));
+
+ return newTokens;
+ }, [onChange]);
+
+ const shouldRenderClearButton = React.useMemo(
+ () => isClearable && externalTokens.length !== defaultTokens.length && isEditable,
+ [isClearable, externalTokens.length, defaultTokens.length, isEditable],
+ );
+
+ return React.useMemo(
+ () => ({
+ state: {
+ tokens,
+ wrapperRef,
+ defaultTokens,
+ fields,
+ isEditable,
+ isClearable: shouldRenderClearButton,
+ placeholder: isEditable ? placeholder : undefined,
+ className,
+ },
+ callbacks: {
+ onApplyChanges,
+ onChangeToken,
+ onChangeTokens,
+ onRemoveToken,
+ onClearInput,
+ onUndo,
+ onRedo,
+ },
+ }),
+ [
+ tokens,
+ defaultTokens,
+ fields,
+ isEditable,
+ shouldRenderClearButton,
+ placeholder,
+ className,
+ onApplyChanges,
+ onChangeToken,
+ onChangeTokens,
+ onRemoveToken,
+ onClearInput,
+ onUndo,
+ onRedo,
+ ],
+ );
+};
diff --git a/src/components/TokenizedInput/i18n/en.json b/src/components/TokenizedInput/i18n/en.json
new file mode 100644
index 00000000..73d2fe53
--- /dev/null
+++ b/src/components/TokenizedInput/i18n/en.json
@@ -0,0 +1,3 @@
+{
+ "suggestions.items_not_found": "No matches for the search string «{{text}}»"
+}
diff --git a/src/components/TokenizedInput/i18n/index.ts b/src/components/TokenizedInput/i18n/index.ts
new file mode 100644
index 00000000..5a26cc5d
--- /dev/null
+++ b/src/components/TokenizedInput/i18n/index.ts
@@ -0,0 +1,8 @@
+import {addComponentKeysets} from '@gravity-ui/uikit/i18n';
+
+import {NAMESPACE} from '../../utils/cn';
+
+import en from './en.json';
+import ru from './ru.json';
+
+export default addComponentKeysets({en, ru}, `${NAMESPACE}adaptive-tabs`);
diff --git a/src/components/TokenizedInput/i18n/ru.json b/src/components/TokenizedInput/i18n/ru.json
new file mode 100644
index 00000000..8923939b
--- /dev/null
+++ b/src/components/TokenizedInput/i18n/ru.json
@@ -0,0 +1,3 @@
+{
+ "suggestions.items_not_found": "По запросу «{{text}}» ничего не найдено"
+}
diff --git a/src/components/TokenizedInput/index.ts b/src/components/TokenizedInput/index.ts
new file mode 100644
index 00000000..1f1c3bf3
--- /dev/null
+++ b/src/components/TokenizedInput/index.ts
@@ -0,0 +1,5 @@
+export {useTokenizedInput, useTokenizedInputComponents} from './context';
+export * from './components';
+export * from './types';
+export * as tokenizedInputUtils from './utils';
+export {TokenizedInput} from './TokenizedInput';
diff --git a/src/components/TokenizedInput/types.ts b/src/components/TokenizedInput/types.ts
new file mode 100644
index 00000000..95fe25bb
--- /dev/null
+++ b/src/components/TokenizedInput/types.ts
@@ -0,0 +1,241 @@
+import * as React from 'react';
+
+import {
+ TokenizedInputFieldProps as FieldProps,
+ TokenizedInputSuggestionsProps as SuggestionsProps,
+ TokenizedInputTokenProps as TokenProps,
+} from './components';
+
+export type TokenValueBase = Record;
+
+export interface Token {
+ /** Token ID */
+ id: string;
+ /** Token field values */
+ value: T;
+ /** Whether this is a new (in-progress) token */
+ isNew: boolean;
+ /** Extra options */
+ options?: {
+ readOnlyFields?: (keyof T)[];
+ notRemovable?: boolean;
+ };
+ /** Validation errors */
+ errors?: Partial>;
+}
+
+export type TokenOnKeyDownOptions = {
+ /** Current token */
+ token: Token;
+ /** Current focus */
+ focus: TokenFocus;
+ /** Keydown event */
+ event: React.KeyboardEvent;
+ /** Caret position */
+ offset: number;
+ /** Focus handler */
+ onFocus: (v: TokenFocus) => void;
+ /** Updates a single token */
+ onChange: (idx: number, v: Partial) => void;
+ /** Applies pending changes */
+ onApply: (currentTokens?: boolean) => void;
+};
+
+export type TokenFieldKeyAction = {
+ /** Key matcher */
+ key: string | ((event: React.KeyboardEvent) => boolean);
+ /** Action */
+ action?: (v: TokenOnKeyDownOptions) => void;
+};
+
+export type TokenField = {
+ /** Field key */
+ key: keyof T;
+ /** Field className */
+ className?: string;
+ /** Keyboard actions for this field */
+ specialKeysActions?: TokenFieldKeyAction[];
+};
+
+export type TokenFocus = {
+ /** Token index */
+ idx: number;
+ /** Field key */
+ key: keyof T;
+ /** Cursor position (used to initialize focus) */
+ offset?: number;
+ /** Skip focus boundary checks (useful for suggestions) */
+ ignoreChecks?: boolean;
+};
+
+export type TokenizedSuggestionContext = {
+ /** Token index */
+ idx: number;
+ /** Field key */
+ key: keyof T;
+ /** Current field value */
+ value: string;
+ /** Current cursor position */
+ offset: number;
+ /** Current selection */
+ selection?: [number, number];
+ /** Token list */
+ tokens: Token[];
+};
+
+export type TokenizedSuggestionsItem = {
+ /** Label shown in the list */
+ label: React.ReactNode;
+ /** Value used for fuzzy search */
+ search: string;
+ /** Partial token values to apply */
+ value: Partial;
+ /** Focus to move to after selection */
+ focus?: TokenFocus;
+ /** Whether the item is preselected; if several match, the first wins */
+ preselected?: boolean;
+ /** Sort position */
+ sort?: number;
+};
+
+export type TokenizedSuggestions = {
+ /** Suggestion items */
+ items: TokenizedSuggestionsItem[];
+ /** Extra hint for the suggestion set */
+ hint?: React.ReactNode;
+ /** Current word for targeted replacement */
+ currentWord?: {
+ value: string;
+ offset: number;
+ position: {
+ start: number;
+ end: number;
+ };
+ };
+ /** Extra options */
+ options?: {
+ isFilterable?: boolean;
+ showEmptyState?: boolean;
+ };
+};
+
+export type TokenPlaceholderGeneratorFn = (
+ tokenType: 'new' | 'regular',
+ tokenValue: T,
+ idx: number,
+) => string | undefined;
+
+export type TokenizedInputInfo = {
+ state: {
+ /** Token list */
+ tokens: Token[];
+ /** Token fields */
+ fields: TokenField[];
+ /** Whether editing is allowed */
+ isEditable: boolean;
+ /** Whether full clear is allowed */
+ isClearable: boolean;
+ /** Placeholder for the new token */
+ placeholder?: string | TokenPlaceholderGeneratorFn;
+ /** Wrapper className */
+ className?: string;
+ /** Wrapper ref */
+ wrapperRef: React.RefObject;
+ };
+ callbacks: {
+ /** Applies pending changes */
+ onApplyChanges: (currentTokens?: boolean) => void;
+ /** Updates one token */
+ onChangeToken: (idx: number, newValue: Partial) => Token[];
+ /** Replaces all tokens */
+ onChangeTokens: (tokens: Token[]) => Token[];
+ /** Removes a token */
+ onRemoveToken: (idx: number) => Token[];
+ /** Clears the input using defaultTokens */
+ onClearInput: () => Token[];
+ /** Undo */
+ onUndo: () => Token[];
+ /** Redo */
+ onRedo: () => Token[];
+ };
+};
+
+export type TokenizedInputFocusInfo = {
+ state: {
+ /** Current focus */
+ focus: TokenFocus | undefined;
+ /** Autofocus */
+ autoFocus?: boolean;
+ };
+ callbacks: {
+ /** Focus handler */
+ onFocus: (v: TokenFocus) => void;
+ /** Blur handler */
+ onBlur: () => void;
+ /** Neighbor field/token focus rules */
+ getFocusRules: (v: TokenFocus) => {
+ nextField: TokenFocus;
+ prevField: TokenFocus;
+ nextToken: TokenFocus;
+ prevToken: TokenFocus;
+ };
+ };
+};
+
+export interface TokenizedInputData {
+ /** Token values */
+ tokens: T[];
+ /** Defaults applied on full clear */
+ defaultTokens?: T[];
+ /** Maps raw tokens to internal token shape */
+ transformTokens?: (tokens: T[]) => Token[];
+ /** Validates a token */
+ validateToken?: ((token: T) => Partial> | undefined) | false;
+ /** Formats a token value */
+ formatToken?: (token: T) => T;
+ /** Field definitions; order matches display order */
+ fields: TokenField[];
+ /** Wrapper className */
+ className?: string;
+ /** Placeholder for the new token */
+ placeholder?: string | TokenPlaceholderGeneratorFn;
+ /** Whether editing is allowed */
+ isEditable?: boolean;
+ /** Whether full clear is allowed */
+ isClearable?: boolean;
+ /** Suggestions debounce delay; default 150ms; per-field overrides are supported (useful for prebuilt suggestion lists) */
+ debounceDelay?: number | Record;
+ /** When debounce flushes: `focus-input` runs debounce on focus change; `focus-field` does not debounce on focus change */
+ debounceFlushStrategy?: 'focus-input' | 'focus-field';
+ /** Autofocus the new token */
+ autoFocus?: boolean;
+ /** Keydown handler; return true to stop further handling */
+ onKeyDown?: (v: TokenOnKeyDownOptions) => boolean;
+ /** Token list change handler */
+ onChange: (newTokens: T[]) => void;
+ /** Fetches suggestions */
+ onSuggest?: (
+ suggestCtx: TokenizedSuggestionContext,
+ ) => TokenizedSuggestions | Promise>;
+ /** Render suggestions full width below the input */
+ fullWidthSuggestions?: boolean;
+ /** onFocus callback */
+ onFocus?: () => void;
+ /** onBlur callback */
+ onBlur?: () => void;
+ /** Return true to allow blur, false to prevent it */
+ shouldAllowBlur?: (e: React.FocusEvent) => boolean;
+}
+
+export type TokenizedInputComposition = {
+ /** Wrapper that handles all key presses */
+ Wrapper: React.ComponentType>;
+ /** Renders the token list */
+ TokenList: React.ComponentType<{}>;
+ /** Token component */
+ Token: React.ComponentType;
+ /** Input field inside a token */
+ Field: React.ComponentType;
+ /** Suggestions; fully custom UIs should be wrapped with FieldComponent.Popup */
+ Suggestions: React.ComponentType;
+};
diff --git a/src/components/TokenizedInput/undoredo-manager.ts b/src/components/TokenizedInput/undoredo-manager.ts
new file mode 100644
index 00000000..fcabd74e
--- /dev/null
+++ b/src/components/TokenizedInput/undoredo-manager.ts
@@ -0,0 +1,100 @@
+import cloneDeep from 'lodash/cloneDeep';
+import isEqual from 'lodash/isEqual';
+
+type UndoRedoState = {
+ value: T;
+ next: T[];
+ prev: T[];
+};
+
+export class UndoRedoManager {
+ _state: UndoRedoState | undefined = undefined;
+
+ constructor(value: T) {
+ this._state = {
+ value: cloneDeep(value),
+ next: [],
+ prev: [],
+ };
+ }
+
+ private get state() {
+ if (!this._state) {
+ return {
+ value: {} as T,
+ next: [],
+ prev: [],
+ };
+ }
+
+ return this._state;
+ }
+
+ init(value: T) {
+ this._state = {
+ value: cloneDeep(value),
+ next: [],
+ prev: [],
+ };
+ }
+
+ update(value: T, force?: boolean) {
+ if (isEqual(value, this.state.value)) {
+ return;
+ }
+
+ const prev = force
+ ? [...this.state.prev]
+ : [...this.state.prev, cloneDeep(this.state.value)];
+
+ this._state = {
+ value: cloneDeep(value),
+ next: [],
+ prev,
+ };
+
+ if (this.state.prev.length > 100) {
+ this.state.prev.shift();
+ }
+ }
+
+ undo(): T {
+ const prevState = this.state.prev.pop();
+
+ if (!prevState) {
+ return this.getValue();
+ }
+
+ const newNext = cloneDeep(this.state.value);
+
+ this._state = {
+ ...this.state,
+ value: cloneDeep(prevState),
+ next: [...this.state.next, newNext],
+ };
+
+ return this.getValue();
+ }
+
+ redo(): T {
+ const nextState = this.state.next.pop();
+
+ if (!nextState) {
+ return this.getValue();
+ }
+
+ const newPrev = cloneDeep(this.state.value);
+
+ this._state = {
+ ...this.state,
+ value: cloneDeep(nextState),
+ prev: [...this.state.prev, newPrev],
+ };
+
+ return this.getValue();
+ }
+
+ getValue(): T {
+ return cloneDeep(this.state.value);
+ }
+}
diff --git a/src/components/TokenizedInput/utils.ts b/src/components/TokenizedInput/utils.ts
new file mode 100644
index 00000000..a12bb653
--- /dev/null
+++ b/src/components/TokenizedInput/utils.ts
@@ -0,0 +1,148 @@
+import {getUniqId} from '@gravity-ui/uikit';
+
+import {Token, TokenField, TokenFieldKeyAction, TokenValueBase} from './types';
+
+export {fuzzySearch} from './components/Suggestions';
+
+export const getDefaultTokenValue = (fields: TokenField[]): T => {
+ return fields.reduce((acc, cur) => ({...acc, [cur.key]: ''}), {} as T);
+};
+
+export const getValuesFromTokens = (tokens: Token[]): T[] => {
+ return tokens.map(({id: _id, value}) => value);
+};
+
+export const removeEmptyTokens = (tokens: Token[]): Token[] => {
+ return tokens.filter((token) => {
+ return !Object.values(token.value).every((v) => v.trim() === '');
+ });
+};
+
+export const removeNewTokens = (tokens: Token[]): Token[] => {
+ return tokens.filter((token) => {
+ return !token.isNew;
+ });
+};
+
+export const defaultValidateToken = (token: T) => {
+ const errors = Object.entries(token).reduce>>(
+ (map, [key, value]) => {
+ if (value.trim()) {
+ return map;
+ }
+ return {...map, [key]: 'Empty value'};
+ },
+ {},
+ );
+
+ if (Object.keys(errors).length === 0) {
+ return undefined;
+ }
+
+ return errors;
+};
+
+export const defaultTransformTokens = (tokens: T[]): Token[] => {
+ return tokens.map((value) => {
+ return {
+ id: `tokenId${getUniqId()}`,
+ isNew: false,
+ value,
+ };
+ });
+};
+
+const findPairBySymbol = (symbol: string, pairs: Record) => {
+ const pair = Object.entries(pairs).find(([o, c]) => o === symbol || c === symbol);
+
+ return pair ?? [];
+};
+
+const findUnclosedPairs = (value: string, pairs: Record) => {
+ const stack: string[] = [];
+ const symbols = value.split('');
+
+ for (const symbol of symbols) {
+ const pair = findPairBySymbol(symbol, pairs);
+
+ if (!pair.length) {
+ continue;
+ }
+
+ if (stack.at(-1) === pair[0]) {
+ stack.pop();
+ } else {
+ stack.push(symbol);
+ }
+ }
+
+ return stack;
+};
+
+export const autoClosingPairsAction = (
+ fieldKey: keyof T,
+ pairs: Record = {
+ "'": "'",
+ '"': '"',
+ '{': '}',
+ '(': ')',
+ '[': ']',
+ },
+): TokenFieldKeyAction => ({
+ key: (e) => Boolean(findPairBySymbol(e.key, pairs).length),
+ action: ({token, offset, onFocus, focus, onChange, event}) => {
+ const value = token.value.value ?? '';
+
+ const [openSymbol, closeSymbol] = findPairBySymbol(event.key, pairs);
+
+ if (!openSymbol || !closeSymbol) {
+ return;
+ }
+
+ const input = event.target as HTMLInputElement;
+
+ const startOffset = input.selectionStart || offset;
+ const endOffset = input.selectionEnd || offset;
+
+ // check for unclosed pair
+ if (event.key === closeSymbol) {
+ const unclosedPairs = findUnclosedPairs(value.slice(0, offset), pairs);
+
+ // if there is unclosed pair and the next symbol is not closeSymbol
+ // then forcing default event
+ if (
+ unclosedPairs.length &&
+ unclosedPairs.at(-1) === closeSymbol &&
+ value[endOffset] !== closeSymbol
+ ) {
+ return;
+ }
+ }
+
+ // if the next symbol is closeSymbol
+ // prevent it from being doubled
+ if (event.key === closeSymbol && value[endOffset] === closeSymbol) {
+ event.preventDefault();
+ onFocus({...focus, offset: offset + 1});
+
+ return;
+ }
+
+ const nextSymbolIsWordSymbol =
+ Boolean(value[endOffset]) && /\w+/g.test(value[endOffset]) && startOffset === endOffset;
+
+ if (event.key !== openSymbol || nextSymbolIsWordSymbol) {
+ return;
+ }
+
+ event.preventDefault();
+
+ onFocus({...focus, offset: endOffset + 1});
+ onChange(focus.idx, {
+ [fieldKey]:
+ value.slice(0, startOffset) +
+ `${openSymbol}${value.slice(startOffset, endOffset)}${closeSymbol}` +
+ value.slice(endOffset),
+ } as Partial);
+ },
+});
diff --git a/src/components/index.ts b/src/components/index.ts
index e235342f..e84e2e24 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -16,5 +16,6 @@ export * from './StoriesGroup';
export * from './ConfirmDialog';
export * from './Reactions';
export * from './Gallery';
+export * from './TokenizedInput';
export type * from './types';