From 0396b2d09a2783fc126671ccfe7c10df9d0a9e8e Mon Sep 17 00:00:00 2001 From: Alexey Gryzin <> Date: Mon, 23 Mar 2026 11:14:44 +0300 Subject: [PATCH 1/6] feat: added tokenized input --- package-lock.json | 7 + package.json | 1 + src/components/TokenizedInput/README.md | 24 + .../TokenizedInput/TokenizedInput.scss | 295 +++++++++++ .../TokenizedInput/TokenizedInput.tsx | 48 ++ .../__stories__/AsyncSuggestsTemplate.tsx | 283 +++++++++++ .../__stories__/CustomPlaceholderTemplate.tsx | 182 +++++++ .../__stories__/CustomRenderValueTemplate.tsx | 390 +++++++++++++++ .../__stories__/DebounceShowcaseTemplate.tsx | 53 ++ .../__stories__/DefaultTemplate.tsx | 357 +++++++++++++ .../FullWidthSuggestionsTemplate.tsx | 358 +++++++++++++ .../__stories__/MultipleInputTemplate.tsx | 22 + .../__stories__/SingleFieldTemplate.tsx | 101 ++++ .../__stories__/TokenizedInput.stories.tsx | 65 +++ .../TokenizedInput/__stories__/types.ts | 4 + .../components/Content/Content.tsx | 11 + .../components/Content/index.ts | 1 + .../TokenizedInput/components/Field/Field.tsx | 80 +++ .../components/Field/FieldPopup.tsx | 22 + .../TokenizedInput/components/Field/index.ts | 3 + .../components/Field/useField.ts | 211 ++++++++ .../components/Suggestions/Suggestions.tsx | 77 +++ .../Suggestions/SuggestionsList.tsx | 65 +++ .../components/Suggestions/hooks/index.ts | 1 + .../Suggestions/hooks/useSelectSuggestion.ts | 68 +++ .../Suggestions/hooks/useSuggestions.ts | 240 +++++++++ .../hooks/useSuggestionsNavigation.ts | 90 ++++ .../hooks/useSuggestionsPopupOptions.ts | 45 ++ .../components/Suggestions/index.ts | 6 + .../components/Suggestions/types.ts | 29 ++ .../components/Suggestions/utils.test.ts | 76 +++ .../components/Suggestions/utils.ts | 72 +++ .../components/Tokens/Token/NewToken.tsx | 33 ++ .../components/Tokens/Token/RegularToken.tsx | 46 ++ .../components/Tokens/Token/Token.tsx | 32 ++ .../components/Tokens/Token/hooks/index.ts | 3 + .../Tokens/Token/hooks/useNewToken.ts | 76 +++ .../Tokens/Token/hooks/useRegularToken.ts | 97 ++++ .../Tokens/Token/hooks/useTokenCallbacks.ts | 34 ++ .../components/Tokens/Token/index.ts | 6 + .../components/Tokens/Token/types.ts | 3 + .../components/Tokens/TokenList/TokenList.tsx | 18 + .../components/Tokens/TokenList/index.ts | 2 + .../Tokens/TokenList/useTokenList.ts | 19 + .../TokenizedInput/components/Tokens/index.ts | 2 + .../components/Wrapper/Wrapper.tsx | 24 + .../components/Wrapper/hooks/index.ts | 1 + .../Wrapper/hooks/useKeyDownHandler.ts | 470 ++++++++++++++++++ .../components/Wrapper/hooks/useWrapper.ts | 52 ++ .../components/Wrapper/index.ts | 2 + .../TokenizedInput/components/index.ts | 4 + src/components/TokenizedInput/constants.ts | 20 + .../TokenizedInputComponentsContext.tsx | 54 ++ .../context/TokenizedInputContext.tsx | 170 +++++++ .../TokenizedInput/context/index.ts | 2 + src/components/TokenizedInput/hooks/index.ts | 4 + .../hooks/useApplyCallbackOnBlur.ts | 17 + .../hooks/useSuggestionsInitialCall.ts | 31 ++ .../hooks/useTokenizedInputComponentFocus.ts | 33 ++ .../hooks/useTokenizedInputFocus.ts | 194 ++++++++ .../hooks/useTokenizedInputInfo.ts | 219 ++++++++ src/components/TokenizedInput/i18n/en.json | 3 + src/components/TokenizedInput/i18n/index.ts | 8 + src/components/TokenizedInput/i18n/ru.json | 3 + src/components/TokenizedInput/index.ts | 5 + src/components/TokenizedInput/types.ts | 241 +++++++++ .../TokenizedInput/undoredo-manager.ts | 100 ++++ src/components/TokenizedInput/utils.ts | 148 ++++++ src/components/index.ts | 1 + 69 files changed, 5464 insertions(+) create mode 100644 src/components/TokenizedInput/README.md create mode 100644 src/components/TokenizedInput/TokenizedInput.scss create mode 100644 src/components/TokenizedInput/TokenizedInput.tsx create mode 100644 src/components/TokenizedInput/__stories__/AsyncSuggestsTemplate.tsx create mode 100644 src/components/TokenizedInput/__stories__/CustomPlaceholderTemplate.tsx create mode 100644 src/components/TokenizedInput/__stories__/CustomRenderValueTemplate.tsx create mode 100644 src/components/TokenizedInput/__stories__/DebounceShowcaseTemplate.tsx create mode 100644 src/components/TokenizedInput/__stories__/DefaultTemplate.tsx create mode 100644 src/components/TokenizedInput/__stories__/FullWidthSuggestionsTemplate.tsx create mode 100644 src/components/TokenizedInput/__stories__/MultipleInputTemplate.tsx create mode 100644 src/components/TokenizedInput/__stories__/SingleFieldTemplate.tsx create mode 100644 src/components/TokenizedInput/__stories__/TokenizedInput.stories.tsx create mode 100644 src/components/TokenizedInput/__stories__/types.ts create mode 100644 src/components/TokenizedInput/components/Content/Content.tsx create mode 100644 src/components/TokenizedInput/components/Content/index.ts create mode 100644 src/components/TokenizedInput/components/Field/Field.tsx create mode 100644 src/components/TokenizedInput/components/Field/FieldPopup.tsx create mode 100644 src/components/TokenizedInput/components/Field/index.ts create mode 100644 src/components/TokenizedInput/components/Field/useField.ts create mode 100644 src/components/TokenizedInput/components/Suggestions/Suggestions.tsx create mode 100644 src/components/TokenizedInput/components/Suggestions/SuggestionsList.tsx create mode 100644 src/components/TokenizedInput/components/Suggestions/hooks/index.ts create mode 100644 src/components/TokenizedInput/components/Suggestions/hooks/useSelectSuggestion.ts create mode 100644 src/components/TokenizedInput/components/Suggestions/hooks/useSuggestions.ts create mode 100644 src/components/TokenizedInput/components/Suggestions/hooks/useSuggestionsNavigation.ts create mode 100644 src/components/TokenizedInput/components/Suggestions/hooks/useSuggestionsPopupOptions.ts create mode 100644 src/components/TokenizedInput/components/Suggestions/index.ts create mode 100644 src/components/TokenizedInput/components/Suggestions/types.ts create mode 100644 src/components/TokenizedInput/components/Suggestions/utils.test.ts create mode 100644 src/components/TokenizedInput/components/Suggestions/utils.ts create mode 100644 src/components/TokenizedInput/components/Tokens/Token/NewToken.tsx create mode 100644 src/components/TokenizedInput/components/Tokens/Token/RegularToken.tsx create mode 100644 src/components/TokenizedInput/components/Tokens/Token/Token.tsx create mode 100644 src/components/TokenizedInput/components/Tokens/Token/hooks/index.ts create mode 100644 src/components/TokenizedInput/components/Tokens/Token/hooks/useNewToken.ts create mode 100644 src/components/TokenizedInput/components/Tokens/Token/hooks/useRegularToken.ts create mode 100644 src/components/TokenizedInput/components/Tokens/Token/hooks/useTokenCallbacks.ts create mode 100644 src/components/TokenizedInput/components/Tokens/Token/index.ts create mode 100644 src/components/TokenizedInput/components/Tokens/Token/types.ts create mode 100644 src/components/TokenizedInput/components/Tokens/TokenList/TokenList.tsx create mode 100644 src/components/TokenizedInput/components/Tokens/TokenList/index.ts create mode 100644 src/components/TokenizedInput/components/Tokens/TokenList/useTokenList.ts create mode 100644 src/components/TokenizedInput/components/Tokens/index.ts create mode 100644 src/components/TokenizedInput/components/Wrapper/Wrapper.tsx create mode 100644 src/components/TokenizedInput/components/Wrapper/hooks/index.ts create mode 100644 src/components/TokenizedInput/components/Wrapper/hooks/useKeyDownHandler.ts create mode 100644 src/components/TokenizedInput/components/Wrapper/hooks/useWrapper.ts create mode 100644 src/components/TokenizedInput/components/Wrapper/index.ts create mode 100644 src/components/TokenizedInput/components/index.ts create mode 100644 src/components/TokenizedInput/constants.ts create mode 100644 src/components/TokenizedInput/context/TokenizedInputComponentsContext.tsx create mode 100644 src/components/TokenizedInput/context/TokenizedInputContext.tsx create mode 100644 src/components/TokenizedInput/context/index.ts create mode 100644 src/components/TokenizedInput/hooks/index.ts create mode 100644 src/components/TokenizedInput/hooks/useApplyCallbackOnBlur.ts create mode 100644 src/components/TokenizedInput/hooks/useSuggestionsInitialCall.ts create mode 100644 src/components/TokenizedInput/hooks/useTokenizedInputComponentFocus.ts create mode 100644 src/components/TokenizedInput/hooks/useTokenizedInputFocus.ts create mode 100644 src/components/TokenizedInput/hooks/useTokenizedInputInfo.ts create mode 100644 src/components/TokenizedInput/i18n/en.json create mode 100644 src/components/TokenizedInput/i18n/index.ts create mode 100644 src/components/TokenizedInput/i18n/ru.json create mode 100644 src/components/TokenizedInput/index.ts create mode 100644 src/components/TokenizedInput/types.ts create mode 100644 src/components/TokenizedInput/undoredo-manager.ts create mode 100644 src/components/TokenizedInput/utils.ts diff --git a/package-lock.json b/package-lock.json index 8e8fb4ca..d5552626 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@bem-react/classname": "^1.6.0", "@gravity-ui/date-utils": "^2.1.0", "@gravity-ui/icons": "^2.11.0", + "fuzzy-search": "^3.2.1", "lodash": "^4.17.21", "universal-cookie": "^7.2.0" }, @@ -12025,6 +12026,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuzzy-search": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/fuzzy-search/-/fuzzy-search-3.2.1.tgz", + "integrity": "sha512-vAcPiyomt1ioKAsAL2uxSABHJ4Ju/e4UeDM+g1OlR0vV4YhLGMNsdLNvZTpEDY4JCSt0E4hASCNM5t2ETtsbyg==", + "license": "ISC" + }, "node_modules/generic-names": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/generic-names/-/generic-names-4.0.0.tgz", diff --git a/package.json b/package.json index 4917eed3..db89749b 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@bem-react/classname": "^1.6.0", "@gravity-ui/date-utils": "^2.1.0", "@gravity-ui/icons": "^2.11.0", + "fuzzy-search": "^3.2.1", "lodash": "^4.17.21", "universal-cookie": "^7.2.0" }, diff --git a/src/components/TokenizedInput/README.md b/src/components/TokenizedInput/README.md new file mode 100644 index 00000000..5f818436 --- /dev/null +++ b/src/components/TokenizedInput/README.md @@ -0,0 +1,24 @@ +## TokenizedInput + +This component is for writing queries/filters and working with them as tokens. Here, a token is an expression (for example, for the format `key = value` the token would be `User = Ivan`). A distinguishing feature is full keyboard and mouse support (including clicking suggestions). + +### Useful notes when using the component + +- The component is modular; each part can be replaced if needed + - `` — wrapper that handles key presses; must return `children` + - `` — list component that renders an array of tokens + - `` — token component; renders token fields; two variants: `regular` — a standalone full token, and `new` — a new token (looks like a single line and must be the only one) + - `` — token field component + - `` — suggestions component +- For convenience there are shared hooks `useTokenizedInput` and `useTokenizedInputComponents`, and specific hooks `useTokenizedInputWrapper`, `useTokenizedInputList`, `useTokenizedInputNewToken`, `useTokenizedInputRegularToken`, `useTokenizedInputField`, `useTokenizedInputSuggestions` for the matching components. There is also `tokenizedInputUtils` with utilities. + +### Hotkeys + +- `Cmd/Ctrl + Arrow` — move between tokens +- `Option/Alt + Arrow` — move between token fields +- `Cmd/Ctrl + Delete` — delete the current token +- `Cmd/Ctrl + Z` — undo +- `Cmd + Shift + Z / Ctrl + Y` — redo +- `Escape` — close the suggestions menu; press again to remove focus +- `Cmd/Ctrl + I` — open the suggestions menu +- `Enter` — select a suggestion / finish the current token and go to the next (when the suggestions menu is closed) diff --git a/src/components/TokenizedInput/TokenizedInput.scss b/src/components/TokenizedInput/TokenizedInput.scss new file mode 100644 index 00000000..6ea7b375 --- /dev/null +++ b/src/components/TokenizedInput/TokenizedInput.scss @@ -0,0 +1,295 @@ +/* stylelint-disable declaration-no-important */ + +.gc-tokenized-input { + &__wrapper { + position: relative; + + display: flex; + gap: 2px; + + width: 100%; + padding: 2px; + padding-inline-end: 34px; + + border: 1px solid var(--g-color-line-generic); + border-radius: var(--g-border-radius-l); + background-color: var(--g-color-base-background); + + &, + * { + box-sizing: border-box !important; + } + + &_disabled { + pointer-events: none; + + background-color: var(--g-color-base-generic-accent-disabled); + + .gc-tokenized-input__token-wrapper:not(.gc-tokenized-input__token-wrapper_new) { + background: none; + box-shadow: + inset 1px 1px 0 0 var(--g-color-base-generic-accent-disabled), + inset -1px -1px 0 0 var(--g-color-base-generic-accent-disabled); + } + } + + &_focused { + border-color: var(--g-color-line-generic-active); + } + } + + &__clear-button { + position: absolute; + inset-block-start: 0; + inset-inline-end: 0; + + display: flex; + justify-content: center; + align-items: center; + + height: 100%; + padding: 3px 8px; + + cursor: pointer; + + color: unset; + border: unset; + outline: none; + background-color: unset; + } + + &__token-list { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px; + + width: 100%; + } + + &__token { + &-wrapper { + display: flex; + flex-wrap: nowrap; + + max-width: 100%; + height: 24px; + + border-radius: var(--g-border-radius-s); + background-color: var(--g-color-base-generic-accent-disabled); + + &_new { + flex-grow: 1; + + min-width: 300px; + + background-color: transparent; + } + + &_error { + border: 1px solid var(--g-color-text-danger); + + .gc-tokenized-input__field-visible-span { + inset-block-start: -1px; + } + } + } + + &-remove-button { + display: flex; + justify-content: center; + align-items: center; + + height: 100%; + padding: 3px 8px; + padding-inline-start: 4px; + + cursor: pointer; + + color: unset; + border: unset; + border-start-end-radius: var(--g-border-radius-s); + border-end-end-radius: var(--g-border-radius-s); + outline: none; + background-color: unset; + + &:hover { + background-color: var(--g-color-base-generic-hover); + } + } + } + + &__field { + &-wrapper { + position: relative; + + flex-shrink: 0; + + min-width: 1ch; + max-width: 100%; + height: 100%; + padding: 3px 4px; + + transition: color 0.2s; + + &:last-of-type { + flex-shrink: 1; + overflow: hidden; + } + + &:first-child { + padding-inline-start: 8px; + + border-start-start-radius: var(--g-border-radius-s); + border-end-start-radius: var(--g-border-radius-s); + + .gc-tokenized-input__field-input { + inset-inline-start: 8px; + + width: calc(100% - 8px); + } + } + + &:last-child { + padding-inline-end: 8px; + + border-start-end-radius: var(--g-border-radius-s); + border-end-end-radius: var(--g-border-radius-s); + + .gc-tokenized-input__field-input { + width: calc(100% - 8px); + } + } + + &_empty { + flex-shrink: 1; + + width: 100%; + } + + &_focused { + background-color: var(--g-color-base-generic-hover); + } + + &_hoverable { + &:hover { + background-color: var(--g-color-base-generic-hover); + } + } + + &_hidden { + width: 0; + min-width: 0; + max-width: 0; + padding: 0; + + &:last-child, + &:first-child { + padding: 0; + } + + .gc-tokenized-input__field-input { + width: 0; + min-width: 0; + max-width: 0; + padding: 0; + } + } + } + + &-input { + position: absolute; + inset-block-start: 0; + inset-inline-start: 4px; + + width: calc(100% - 4px); + min-width: 2ch; + height: 100%; + padding: 0; + + font-family: inherit; + font-size: inherit; + caret-color: var(--g-color-text-primary); + + color: transparent !important; + border: none; + outline: none; + background: none; + font-feature-settings: none; + font-variant-ligatures: none; + + &::placeholder { + color: var(--g-color-text-hint); + font-weight: normal; + } + } + + &-visible-span { + position: relative; + display: inline-block; + + min-width: 1ch; + + white-space: pre; + font-feature-settings: none; + font-variant-ligatures: none; + + text-overflow: ellipsis; + overflow: hidden; + width: 100%; + + &_focused { + color: var(--g-color-text-primary); + } + + &_placeholder { + color: transparent !important; + } + } + + &-popup { + .g-popup__content { + min-width: unset; + max-width: unset; + } + } + } + + &__suggestions-list { + &-wrapper { + display: flex; + overflow-y: auto; + flex-direction: column; + + max-width: 600px; + min-height: 20px; + max-height: 200px; + + &_loading { + width: 300px; + height: 20px; + overflow: hidden; + } + + &_empty { + width: 300px; + overflow: hidden; + } + + &_full-width { + width: 100%; + max-width: none; + } + } + + &-item { + overflow-x: hidden; + + width: 100%; + margin: 0; + padding: 2px 12px; + + cursor: pointer; + } + } +} diff --git a/src/components/TokenizedInput/TokenizedInput.tsx b/src/components/TokenizedInput/TokenizedInput.tsx new file mode 100644 index 00000000..7d23f156 --- /dev/null +++ b/src/components/TokenizedInput/TokenizedInput.tsx @@ -0,0 +1,48 @@ +import { + FieldComponent, + SuggestionsComponent, + TokenComponent, + TokenListComponent, + WrapperComponent, +} from './components'; +import {Content} from './components/Content'; +import {TokenizedInputComponentContextProvider, TokenizedInputContextProvider} from './context'; +import type {TokenValueBase, TokenizedInputComposition, TokenizedInputData} from './types'; + +import './TokenizedInput.scss'; + +type TokenizedInputProps = TokenizedInputData & + Partial; + +function TokenizedInputComponent({ + Wrapper = WrapperComponent, + TokenList = TokenListComponent, + Token = TokenComponent, + Field = FieldComponent, + Suggestions = SuggestionsComponent, + ...props +}: TokenizedInputProps) { + return ( + + + + + + ); +} + +type TTokenizedInput = typeof TokenizedInputComponent & TokenizedInputComposition; + +export const TokenizedInput = TokenizedInputComponent as TTokenizedInput; + +TokenizedInput.Wrapper = WrapperComponent; +TokenizedInput.TokenList = TokenListComponent; +TokenizedInput.Token = TokenComponent; +TokenizedInput.Field = FieldComponent; +TokenizedInput.Suggestions = SuggestionsComponent; diff --git a/src/components/TokenizedInput/__stories__/AsyncSuggestsTemplate.tsx b/src/components/TokenizedInput/__stories__/AsyncSuggestsTemplate.tsx new file mode 100644 index 00000000..54bc30ff --- /dev/null +++ b/src/components/TokenizedInput/__stories__/AsyncSuggestsTemplate.tsx @@ -0,0 +1,283 @@ +import * as React from 'react'; + +import {StoryFn} from '@storybook/react'; + +import {tokenizedInputUtils} from '..'; +import {TokenizedInput} from '../TokenizedInput'; +import { + TokenField, + TokenizedInputData, + TokenizedSuggestionContext, + TokenizedSuggestions, + TokenizedSuggestionsItem, +} from '../types'; + +import {TokenizedComponentType} from './types'; + +type TokenValue = {key: string; operator: string; value: string}; + +const fields: TokenField[] = [ + { + key: 'key', + specialKeysActions: [ + { + key: (e) => e.key === '!' || e.key === '=', + action: ({token, focus, onFocus, onChange, event}) => { + event.preventDefault(); + + if (!token.value.key) { + return; + } + + onFocus({ + ...focus, + key: 'operator', + offset: -1, + ignoreChecks: true, + }); + onChange(focus.idx, {...token.value, operator: event.key}); + }, + }, + { + key: (e) => e.key === ' ' && e.shiftKey, + action: ({token, focus, onFocus, onChange, event}) => { + event.preventDefault(); + + if (!token.value.key) { + return; + } + + onFocus({ + ...focus, + key: 'value', + offset: -1, + ignoreChecks: true, + }); + onChange(focus.idx, {...token.value, operator: '='}); + }, + }, + ], + }, + { + key: 'operator', + specialKeysActions: [ + { + key: (e) => { + if (e.key.length > 1 || e.key === ' ') { + return false; + } + return e.key !== '=' && e.key !== '!' && e.key !== '~'; + }, + action: ({token, focus, onFocus, onChange, event}) => { + event.preventDefault(); + + if (!token.value.operator) { + return; + } + + onFocus({ + ...focus, + key: 'value', + offset: -1, + ignoreChecks: true, + }); + onChange(focus.idx, { + ...token.value, + operator: token.value.operator ?? '=', + value: event.key, + }); + }, + }, + ], + }, + { + key: 'value', + specialKeysActions: [ + tokenizedInputUtils.autoClosingPairsAction('value', { + '{': '}', + '"': '"', + "'": "'", + }), + ], + }, +]; + +const fetchKeys = async () => { + return new Promise((res) => + setTimeout(() => { + res(['Action', 'User', 'Name', 'Company', 'Rule', 'http-method']); + }, 500), + ); +}; + +const fetchExtendedKeys = async () => { + return new Promise<{value: string; type: string}[]>((res) => + setTimeout(() => { + res([ + ...['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].map((value) => ({ + value, + type: 'http-method', + })), + ...[ + 'Ivan Petrov', + 'Ilya Davydov', + 'Dmitriy Demchenkov', + 'Klim Zhukov', + 'Anton Detryuk', + ].map((value) => ({value, type: 'User'})), + ]); + }, 1000), + ); +}; + +const fetchValues = async (key: string) => { + return new Promise((res) => + setTimeout(() => { + if (key === 'User') { + res([ + 'Ivan Petrov', + 'Ilya Davydov', + 'Dmitriy Demchenkov', + 'Klim Zhukov', + 'Anton Detryuk', + ]); + return; + } + if (key === 'Company') { + res(['Yandex', 'Yandex Cloud']); + return; + } + if (key === 'http-method') { + res(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']); + return; + } + + res(['fetch', 'slice', 'sort']); + }, 500), + ); +}; + +const onSuggest = async ( + ctx: TokenizedSuggestionContext, +): Promise> => { + if (ctx.idx > 6) { + await Promise.all([fetchKeys(), fetchExtendedKeys()]); + + return {items: [], options: {showEmptyState: false}}; + } + + const token = ctx.tokens[ctx.idx]; + + switch (ctx.key) { + case 'key': { + const [keys, extendedKeys] = await Promise.all([fetchKeys(), fetchExtendedKeys()]); + return { + items: [ + ...keys.map((item) => { + return { + label: item, + search: item, + value: {key: item}, + focus: { + idx: ctx.idx, + key: 'operator', + offset: -1, + ignoreChecks: true, + }, + sort: 1, + }; + }), + ...extendedKeys.map((item) => { + return { + label: ( + + {item.value}   + + {`(${item.type})`} + + + ), + search: item.value + item.type, + value: {key: item.type, operator: '=', value: item.value}, + focus: {idx: ctx.idx + 1, key: 'key', offset: -1}, + sort: 2, + }; + }), + ] as TokenizedSuggestionsItem[], + }; + } + case 'operator': { + const operators = ['=', '==', '!=', '!==']; + + return { + items: operators.map((item) => { + return { + label: item, + search: item, + value: {operator: item}, + focus: {idx: ctx.idx, key: 'value', offset: -1}, + }; + }), + options: { + isFilterable: false, + }, + }; + } + case 'value': { + const values = await fetchValues(token.value.key); + + if (token.value.key === 'User') { + return { + items: values.map((item) => { + return { + label: ( +
+ {item} + + {item.split(' ').join('').toLowerCase() + '@yandex.ru'} + +
+ ), + search: item, + value: {value: item}, + focus: {idx: ctx.idx + 1, key: 'key', offset: -1}, + }; + }), + }; + } + return { + items: values.map((item) => { + return { + label: item, + search: item, + value: {value: item}, + focus: {idx: ctx.idx + 1, key: 'key', offset: -1}, + }; + }), + }; + } + default: { + return {items: [], options: {showEmptyState: false}}; + } + } +}; + +export const AsyncSuggestsTokenizedInput = ( + props: Omit, 'onChange' | 'fields' | 'onSuggest'>, +) => { + const [tokens, setTokens] = React.useState(props.tokens ?? []); + + return ( + + ); +}; + +export const AsyncSuggestsTemplate: StoryFn> = (args) => { + return ; +}; diff --git a/src/components/TokenizedInput/__stories__/CustomPlaceholderTemplate.tsx b/src/components/TokenizedInput/__stories__/CustomPlaceholderTemplate.tsx new file mode 100644 index 00000000..b50661b0 --- /dev/null +++ b/src/components/TokenizedInput/__stories__/CustomPlaceholderTemplate.tsx @@ -0,0 +1,182 @@ +import * as React from 'react'; + +import {StoryFn} from '@storybook/react'; + +import {TokenPlaceholderGeneratorFn, tokenizedInputUtils} from '..'; +import {TokenizedInput} from '../TokenizedInput'; +import { + TokenField, + TokenizedInputData, + TokenizedSuggestionContext, + TokenizedSuggestions, + TokenizedSuggestionsItem, +} from '../types'; + +import {TokenizedComponentType} from './types'; + +type TokenValue = {key: string; operator: string; value: string}; + +const fields: TokenField[] = [ + { + key: 'key', + specialKeysActions: [ + { + key: (e) => e.key === '!' || e.key === '=', + action: ({token, focus, onFocus, onChange, event}) => { + event.preventDefault(); + + if (!token.value.key) { + return; + } + + onFocus({ + ...focus, + key: 'operator', + offset: -1, + ignoreChecks: true, + }); + onChange(focus.idx, {...token.value, operator: event.key}); + }, + }, + { + key: (e) => e.key === ' ' && e.shiftKey, + action: ({token, focus, onFocus, onChange, event}) => { + event.preventDefault(); + + if (!token.value.key) { + return; + } + + onFocus({ + ...focus, + key: 'value', + offset: -1, + ignoreChecks: true, + }); + onChange(focus.idx, {...token.value, operator: '='}); + }, + }, + ], + }, + { + key: 'operator', + specialKeysActions: [ + { + key: (e) => { + if (e.key.length > 1 || e.key === ' ') { + return false; + } + return e.key !== '=' && e.key !== '!' && e.key !== '~'; + }, + action: ({token, focus, onFocus, onChange, event}) => { + event.preventDefault(); + + if (!token.value.operator) { + return; + } + + onFocus({ + ...focus, + key: 'value', + offset: -1, + ignoreChecks: true, + }); + onChange(focus.idx, { + ...token.value, + operator: token.value.operator ?? '=', + value: event.key, + }); + }, + }, + ], + }, + { + key: 'value', + specialKeysActions: [ + tokenizedInputUtils.autoClosingPairsAction('value', { + '{': '}', + '"': '"', + "'": "'", + '(': ')', + }), + ], + }, +]; + +const onSuggest = ( + ctx: TokenizedSuggestionContext, +): TokenizedSuggestions => { + switch (ctx.key) { + case 'key': { + return { + items: [ + ...['message'].map((item) => { + return { + label: item, + search: item, + value: {key: item}, + focus: { + idx: ctx.idx, + key: 'operator', + offset: -1, + ignoreChecks: true, + }, + sort: 1, + }; + }), + ] as TokenizedSuggestionsItem[], + }; + } + case 'operator': { + return { + items: ['=', '==', '!=', '!=='].map((item) => { + return { + label: item, + search: item, + value: {operator: item}, + focus: {idx: ctx.idx, key: 'value', offset: -1}, + }; + }), + options: { + isFilterable: false, + }, + }; + } + } + + return {items: [], options: {showEmptyState: false}}; +}; + +export const CustomPlaceholderTokenizedInput = ( + props: Omit, 'tokens' | 'onChange' | 'fields' | 'onSuggest'>, +) => { + const [tokens, setTokens] = React.useState([]); + + const placeholder: TokenPlaceholderGeneratorFn = React.useCallback( + (tokenType, token, idx) => { + if (token.key === 'message' && idx === 2) { + return 'Enter a string'; + } + if (tokenType === 'new' && idx === 0) { + return 'Enter a value'; + } + return undefined; + }, + [], + ); + + return ( + + ); +}; + +export const CustomPlaceholderTemplate: StoryFn> = (args) => { + return ; +}; diff --git a/src/components/TokenizedInput/__stories__/CustomRenderValueTemplate.tsx b/src/components/TokenizedInput/__stories__/CustomRenderValueTemplate.tsx new file mode 100644 index 00000000..30fc9e0d --- /dev/null +++ b/src/components/TokenizedInput/__stories__/CustomRenderValueTemplate.tsx @@ -0,0 +1,390 @@ +import * as React from 'react'; + +import {StoryFn} from '@storybook/react'; + +import {TokenizedInputFieldProps, tokenizedInputUtils} from '..'; +import {TokenizedInput} from '../TokenizedInput'; +import { + TokenField, + TokenizedInputData, + TokenizedSuggestionContext, + TokenizedSuggestions, + TokenizedSuggestionsItem, +} from '../types'; + +import {TokenizedComponentType} from './types'; + +type TokenValue = {key: string; operator: string; value: string}; + +const fields: TokenField[] = [ + { + key: 'key', + specialKeysActions: [ + { + key: (e) => e.key === '!' || e.key === '=', + action: ({token, focus, onFocus, onChange, event}) => { + event.preventDefault(); + + if (!token.value.key) { + return; + } + + onFocus({ + ...focus, + key: 'operator', + offset: -1, + ignoreChecks: true, + }); + onChange(focus.idx, {...token.value, operator: event.key}); + }, + }, + { + key: (e) => e.key === ' ' && e.shiftKey, + action: ({token, focus, onFocus, onChange, event}) => { + event.preventDefault(); + + if (!token.value.key) { + return; + } + + onFocus({ + ...focus, + key: 'value', + offset: -1, + ignoreChecks: true, + }); + onChange(focus.idx, {...token.value, operator: '='}); + }, + }, + ], + }, + { + key: 'operator', + specialKeysActions: [ + { + key: (e) => { + if (e.key.length > 1 || e.key === ' ') { + return false; + } + return e.key !== '=' && e.key !== '!' && e.key !== '~'; + }, + action: ({token, focus, onFocus, onChange, event}) => { + event.preventDefault(); + + if (!token.value.operator) { + return; + } + + onFocus({ + ...focus, + key: 'value', + offset: -1, + ignoreChecks: true, + }); + onChange(focus.idx, { + ...token.value, + operator: token.value.operator ?? '=', + value: event.key, + }); + }, + }, + ], + }, + { + key: 'value', + specialKeysActions: [ + tokenizedInputUtils.autoClosingPairsAction('value', { + '{': '}', + '"': '"', + "'": "'", + }), + ], + }, +]; + +const onSuggest = ( + ctx: TokenizedSuggestionContext, +): TokenizedSuggestions => { + if (ctx.idx > 6) { + return {items: [], options: {showEmptyState: false}}; + } + + const token = ctx.tokens[ctx.idx]; + + switch (ctx.key) { + case 'key': { + return { + items: [ + ...['Action', 'User', 'Name', 'Company', 'Rule', 'http-method'].map((item) => { + return { + label: item, + search: item, + value: {key: item}, + focus: { + idx: ctx.idx, + key: 'operator', + offset: -1, + ignoreChecks: true, + }, + sort: 1, + }; + }), + ] as TokenizedSuggestionsItem[], + }; + } + case 'operator': { + return { + items: ['=', '==', '!=', '!=='].map((item) => { + return { + label: item, + search: item, + value: {operator: item}, + focus: {idx: ctx.idx, key: 'value', offset: -1}, + }; + }), + options: { + isFilterable: false, + }, + }; + } + case 'value': { + if (token.value.key === 'User') { + return { + items: [ + 'Ivan Petrov', + 'Ilya Davydov', + 'Dmitriy Demchenkov', + 'Klim Zhukov', + 'Anton Detryuk', + ].map((item) => { + return { + label: ( +
+ {item} + + {item.split(' ').join('').toLowerCase() + '@yandex.ru'} + +
+ ), + search: item, + value: {value: item}, + focus: {idx: ctx.idx + 1, key: 'key', offset: -1}, + }; + }), + }; + } + if (token.value.key === 'Company') { + return { + items: ['Yandex', 'Yandex Cloud'].map((item) => { + return { + label: item, + search: item, + value: {value: item}, + focus: {idx: ctx.idx + 1, key: 'key', offset: -1}, + }; + }), + }; + } + + if (token.value.key === 'Rule') { + return { + items: [ + 'abs', + 'alert_evaluation_history', + 'alias', + 'as_vector', + 'asap', + 'avg', + 'binomial_distribution', + 'bottom', + 'bottom_avg', + 'bottom_count', + 'bottom_last', + 'bottom_max', + 'bottom_min', + 'bottom_sum', + 'ceil', + 'constant_line', + 'count', + 'delta_to_rate', + 'derivative', + 'diff', + 'drop_above', + 'drop_below', + 'drop_empty_lines', + 'drop_empty_series', + 'drop_head', + 'drop_if', + 'drop_label', + 'drop_nan', + 'drop_tail', + 'exp', + 'exponential_trend', + 'fallback', + 'filter_by_time', + 'flatten', + 'floor', + 'fract', + 'get_label', + 'get_timestamps', + 'grid_step', + 'group_by_time', + 'group_lines', + 'head', + 'heaviside', + 'histogram_avg', + 'histogram_cdfp', + 'histogram_count', + 'histogram_percentile', + 'histogram_sum', + 'inf', + 'integral', + 'integrate', + 'integrate_fn', + 'iqr', + 'kronos_adjusted', + 'kronos_mean', + 'kronos_variance', + 'last', + 'linear_trend', + 'load', + 'load1', + 'log', + 'logarithmic_trend', + 'max', + 'median', + 'min', + 'mod', + 'moving_avg', + 'moving_percentile', + 'moving_sum', + 'non_negative_derivative', + 'percentile', + 'percentile_group_lines', + 'pow', + 'ramp', + 'random', + 'random01', + 'rate_to_delta', + 'relabel', + 'replace_nan', + 'round', + 'seasonal_adjusted', + 'seasonal_mean', + 'seasonal_variance', + 'series_avg', + 'series_count', + 'series_max', + 'series_min', + 'series_percentile', + 'series_sum', + 'shift', + 'sign', + 'single', + 'size', + 'sqr', + 'sqrt', + 'std', + 'sum', + 'summary_avg', + 'summary_count', + 'summary_last', + 'summary_max', + 'summary_min', + 'summary_sum', + 'tail', + 'take_if', + 'time_interval_begin', + 'time_interval_end', + 'timestamps', + 'to_fixed', + 'to_string', + 'to_vector', + 'top', + 'top_avg', + 'top_count', + 'top_last', + 'top_max', + 'top_min', + 'top_sum', + 'transform', + 'trunc', + ].map((item) => { + return { + label: item, + search: item, + value: {value: item}, + focus: {idx: ctx.idx + 1, key: 'key', offset: -1}, + }; + }), + }; + } + + return { + items: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'fetch', 'slice', 'sort'].map( + (item) => { + return { + label: item, + search: item, + value: {value: item}, + focus: {idx: ctx.idx + 1, key: 'key', offset: -1}, + }; + }, + ), + }; + } + default: { + return {items: [], options: {showEmptyState: false}}; + } + } +}; + +const renderValue: TokenizedInputFieldProps['renderValue'] = ({ + fieldKey, + isFocused, + isNew, + visibleValue, +}) => { + if (isNew || isFocused) { + return visibleValue; + } + + if (fieldKey === 'key') { + return {visibleValue}; + } + + if (fieldKey === 'operator') { + return {visibleValue}; + } + + if (fieldKey === 'value') { + return {visibleValue}; + } + + return visibleValue; +}; + +const Field = (props: TokenizedInputFieldProps) => { + return ; +}; + +export const CustomRenderValueTokenizedInput = ( + props: Omit, 'tokens' | 'onChange' | 'fields' | 'onSuggest'>, +) => { + const [tokens, setTokens] = React.useState([]); + + return ( + + ); +}; + +export const CustomRenderValueTemplate: StoryFn> = (args) => { + return ; +}; diff --git a/src/components/TokenizedInput/__stories__/DebounceShowcaseTemplate.tsx b/src/components/TokenizedInput/__stories__/DebounceShowcaseTemplate.tsx new file mode 100644 index 00000000..25994b25 --- /dev/null +++ b/src/components/TokenizedInput/__stories__/DebounceShowcaseTemplate.tsx @@ -0,0 +1,53 @@ +/* eslint-disable react/no-unescaped-entities */ + +import {Flex, Text} from '@gravity-ui/uikit'; +import {StoryFn} from '@storybook/react'; + +import {AsyncSuggestsTokenizedInput} from './AsyncSuggestsTemplate'; +import {DefaultTokenizedInput} from './DefaultTemplate'; +import {TokenizedComponentType} from './types'; + +type TokenValue = {key: string; operator: string; value: string}; + +export const DebounceShowcaseTemplate: StoryFn> = (args) => { + return ( + + + Strategy 'focus-field', 500ms debounce, synchronous suggestions + + + + Strategy 'focus-input', 500ms debounce, synchronous suggestions + + + + Strategy 'focus-field', per-field debounce, asynchronous suggestions + + + + Strategy 'focus-input', per-field debounce, asynchronous suggestions + + + + ); +}; diff --git a/src/components/TokenizedInput/__stories__/DefaultTemplate.tsx b/src/components/TokenizedInput/__stories__/DefaultTemplate.tsx new file mode 100644 index 00000000..aca4db4c --- /dev/null +++ b/src/components/TokenizedInput/__stories__/DefaultTemplate.tsx @@ -0,0 +1,357 @@ +import * as React from 'react'; + +import {StoryFn} from '@storybook/react'; + +import {tokenizedInputUtils} from '..'; +import {TokenizedInput} from '../TokenizedInput'; +import { + TokenField, + TokenizedInputData, + TokenizedSuggestionContext, + TokenizedSuggestions, + TokenizedSuggestionsItem, +} from '../types'; + +import {TokenizedComponentType} from './types'; + +type TokenValue = {key: string; operator: string; value: string}; + +const fields: TokenField[] = [ + { + key: 'key', + specialKeysActions: [ + { + key: (e) => e.key === '!' || e.key === '=', + action: ({token, focus, onFocus, onChange, event}) => { + event.preventDefault(); + + if (!token.value.key) { + return; + } + + onFocus({ + ...focus, + key: 'operator', + offset: -1, + ignoreChecks: true, + }); + onChange(focus.idx, {...token.value, operator: event.key}); + }, + }, + { + key: (e) => e.key === ' ' && e.shiftKey, + action: ({token, focus, onFocus, onChange, event}) => { + event.preventDefault(); + + if (!token.value.key) { + return; + } + + onFocus({ + ...focus, + key: 'value', + offset: -1, + ignoreChecks: true, + }); + onChange(focus.idx, {...token.value, operator: '='}); + }, + }, + ], + }, + { + key: 'operator', + specialKeysActions: [ + { + key: (e) => { + if (e.key.length > 1 || e.key === ' ') { + return false; + } + return e.key !== '=' && e.key !== '!' && e.key !== '~'; + }, + action: ({token, focus, onFocus, onChange, event}) => { + event.preventDefault(); + + if (!token.value.operator) { + return; + } + + onFocus({ + ...focus, + key: 'value', + offset: -1, + ignoreChecks: true, + }); + onChange(focus.idx, { + ...token.value, + operator: token.value.operator ?? '=', + value: event.key, + }); + }, + }, + ], + }, + { + key: 'value', + specialKeysActions: [ + tokenizedInputUtils.autoClosingPairsAction('value', { + '{': '}', + '"': '"', + "'": "'", + '(': ')', + }), + ], + }, +]; + +const onSuggest = ( + ctx: TokenizedSuggestionContext, +): TokenizedSuggestions => { + const token = ctx.tokens[ctx.idx]; + + switch (ctx.key) { + case 'key': { + return { + items: [ + ...['Action', 'User', 'Name', 'Company', 'Rule', 'http-method'].map((item) => { + return { + label: item, + search: item, + value: {key: item}, + focus: { + idx: ctx.idx, + key: 'operator', + offset: -1, + ignoreChecks: true, + }, + sort: 1, + }; + }), + ] as TokenizedSuggestionsItem[], + }; + } + case 'operator': { + return { + items: ['=', '==', '!=', '!=='].map((item) => { + return { + label: item, + search: item, + value: {operator: item}, + focus: {idx: ctx.idx, key: 'value', offset: -1}, + }; + }), + options: { + isFilterable: false, + }, + }; + } + case 'value': { + if (token.value.key === 'User') { + return { + items: [ + 'Ivan Petrov', + 'Ilya Davydov', + 'Dmitriy Demchenkov', + 'Klim Zhukov', + 'Anton Detryuk', + ].map((item) => { + return { + label: ( +
+ {item} + + {item.split(' ').join('').toLowerCase() + '@yandex.ru'} + +
+ ), + search: item, + value: {value: item}, + focus: {idx: ctx.idx + 1, key: 'key', offset: -1}, + }; + }), + }; + } + if (token.value.key === 'Company') { + return { + items: ['Yandex', 'Yandex Cloud'].map((item) => { + return { + label: item, + search: item, + value: {value: item}, + focus: {idx: ctx.idx + 1, key: 'key', offset: -1}, + }; + }), + }; + } + + if (token.value.key === 'Rule') { + return { + items: [ + 'abs', + 'alert_evaluation_history', + 'alias', + 'as_vector', + 'asap', + 'avg', + 'binomial_distribution', + 'bottom', + 'bottom_avg', + 'bottom_count', + 'bottom_last', + 'bottom_max', + 'bottom_min', + 'bottom_sum', + 'ceil', + 'constant_line', + 'count', + 'delta_to_rate', + 'derivative', + 'diff', + 'drop_above', + 'drop_below', + 'drop_empty_lines', + 'drop_empty_series', + 'drop_head', + 'drop_if', + 'drop_label', + 'drop_nan', + 'drop_tail', + 'exp', + 'exponential_trend', + 'fallback', + 'filter_by_time', + 'flatten', + 'floor', + 'fract', + 'get_label', + 'get_timestamps', + 'grid_step', + 'group_by_time', + 'group_lines', + 'head', + 'heaviside', + 'histogram_avg', + 'histogram_cdfp', + 'histogram_count', + 'histogram_percentile', + 'histogram_sum', + 'inf', + 'integral', + 'integrate', + 'integrate_fn', + 'iqr', + 'kronos_adjusted', + 'kronos_mean', + 'kronos_variance', + 'last', + 'linear_trend', + 'load', + 'load1', + 'log', + 'logarithmic_trend', + 'max', + 'median', + 'min', + 'mod', + 'moving_avg', + 'moving_percentile', + 'moving_sum', + 'non_negative_derivative', + 'percentile', + 'percentile_group_lines', + 'pow', + 'ramp', + 'random', + 'random01', + 'rate_to_delta', + 'relabel', + 'replace_nan', + 'round', + 'seasonal_adjusted', + 'seasonal_mean', + 'seasonal_variance', + 'series_avg', + 'series_count', + 'series_max', + 'series_min', + 'series_percentile', + 'series_sum', + 'shift', + 'sign', + 'single', + 'size', + 'sqr', + 'sqrt', + 'std', + 'sum', + 'summary_avg', + 'summary_count', + 'summary_last', + 'summary_max', + 'summary_min', + 'summary_sum', + 'tail', + 'take_if', + 'time_interval_begin', + 'time_interval_end', + 'timestamps', + 'to_fixed', + 'to_string', + 'to_vector', + 'top', + 'top_avg', + 'top_count', + 'top_last', + 'top_max', + 'top_min', + 'top_sum', + 'transform', + 'trunc', + ].map((item) => { + return { + label: item, + search: item, + value: {value: item}, + focus: {idx: ctx.idx + 1, key: 'key', offset: -1}, + }; + }), + }; + } + + return { + items: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'fetch', 'slice', 'sort'].map( + (item) => { + return { + label: item, + search: item, + value: {value: item}, + focus: {idx: ctx.idx + 1, key: 'key', offset: -1}, + }; + }, + ), + }; + } + default: { + return {items: [], options: {showEmptyState: false}}; + } + } +}; + +export const DefaultTokenizedInput = ( + props: Omit, 'tokens' | 'onChange' | 'fields' | 'onSuggest'>, +) => { + const [tokens, setTokens] = React.useState([]); + + return ( + + ); +}; + +export const DefaultTemplate: StoryFn> = (args) => { + return ; +}; diff --git a/src/components/TokenizedInput/__stories__/FullWidthSuggestionsTemplate.tsx b/src/components/TokenizedInput/__stories__/FullWidthSuggestionsTemplate.tsx new file mode 100644 index 00000000..8073eaaa --- /dev/null +++ b/src/components/TokenizedInput/__stories__/FullWidthSuggestionsTemplate.tsx @@ -0,0 +1,358 @@ +import * as React from 'react'; + +import {StoryFn} from '@storybook/react'; + +import {tokenizedInputUtils} from '..'; +import {TokenizedInput} from '../TokenizedInput'; +import { + TokenField, + TokenizedInputData, + TokenizedSuggestionContext, + TokenizedSuggestions, + TokenizedSuggestionsItem, +} from '../types'; + +import {TokenizedComponentType} from './types'; + +type TokenValue = {key: string; operator: string; value: string}; + +const fields: TokenField[] = [ + { + key: 'key', + specialKeysActions: [ + { + key: (e) => e.key === '!' || e.key === '=', + action: ({token, focus, onFocus, onChange, event}) => { + event.preventDefault(); + + if (!token.value.key) { + return; + } + + onFocus({ + ...focus, + key: 'operator', + offset: -1, + ignoreChecks: true, + }); + onChange(focus.idx, {...token.value, operator: event.key}); + }, + }, + { + key: (e) => e.key === ' ' && e.shiftKey, + action: ({token, focus, onFocus, onChange, event}) => { + event.preventDefault(); + + if (!token.value.key) { + return; + } + + onFocus({ + ...focus, + key: 'value', + offset: -1, + ignoreChecks: true, + }); + onChange(focus.idx, {...token.value, operator: '='}); + }, + }, + ], + }, + { + key: 'operator', + specialKeysActions: [ + { + key: (e) => { + if (e.key.length > 1 || e.key === ' ') { + return false; + } + return e.key !== '=' && e.key !== '!' && e.key !== '~'; + }, + action: ({token, focus, onFocus, onChange, event}) => { + event.preventDefault(); + + if (!token.value.operator) { + return; + } + + onFocus({ + ...focus, + key: 'value', + offset: -1, + ignoreChecks: true, + }); + onChange(focus.idx, { + ...token.value, + operator: token.value.operator ?? '=', + value: event.key, + }); + }, + }, + ], + }, + { + key: 'value', + specialKeysActions: [ + tokenizedInputUtils.autoClosingPairsAction('value', { + '{': '}', + '"': '"', + "'": "'", + '(': ')', + }), + ], + }, +]; + +const onSuggest = ( + ctx: TokenizedSuggestionContext, +): TokenizedSuggestions => { + const token = ctx.tokens[ctx.idx]; + + switch (ctx.key) { + case 'key': { + return { + items: [ + ...['Action', 'User', 'Name', 'Company', 'Rule', 'http-method'].map((item) => { + return { + label: item, + search: item, + value: {key: item}, + focus: { + idx: ctx.idx, + key: 'operator', + offset: -1, + ignoreChecks: true, + }, + sort: 1, + }; + }), + ] as TokenizedSuggestionsItem[], + }; + } + case 'operator': { + return { + items: ['=', '==', '!=', '!=='].map((item) => { + return { + label: item, + search: item, + value: {operator: item}, + focus: {idx: ctx.idx, key: 'value', offset: -1}, + }; + }), + options: { + isFilterable: false, + }, + }; + } + case 'value': { + if (token.value.key === 'User') { + return { + items: [ + 'Ivan Petrov', + 'Ilya Davydov', + 'Dmitriy Demchenkov', + 'Klim Zhukov', + 'Anton Detryuk', + ].map((item) => { + return { + label: ( +
+ {item} + + {item.split(' ').join('').toLowerCase() + '@yandex.ru'} + +
+ ), + search: item, + value: {value: item}, + focus: {idx: ctx.idx + 1, key: 'key', offset: -1}, + }; + }), + }; + } + if (token.value.key === 'Company') { + return { + items: ['Yandex', 'Yandex Cloud'].map((item) => { + return { + label: item, + search: item, + value: {value: item}, + focus: {idx: ctx.idx + 1, key: 'key', offset: -1}, + }; + }), + }; + } + + if (token.value.key === 'Rule') { + return { + items: [ + 'abs', + 'alert_evaluation_history', + 'alias', + 'as_vector', + 'asap', + 'avg', + 'binomial_distribution', + 'bottom', + 'bottom_avg', + 'bottom_count', + 'bottom_last', + 'bottom_max', + 'bottom_min', + 'bottom_sum', + 'ceil', + 'constant_line', + 'count', + 'delta_to_rate', + 'derivative', + 'diff', + 'drop_above', + 'drop_below', + 'drop_empty_lines', + 'drop_empty_series', + 'drop_head', + 'drop_if', + 'drop_label', + 'drop_nan', + 'drop_tail', + 'exp', + 'exponential_trend', + 'fallback', + 'filter_by_time', + 'flatten', + 'floor', + 'fract', + 'get_label', + 'get_timestamps', + 'grid_step', + 'group_by_time', + 'group_lines', + 'head', + 'heaviside', + 'histogram_avg', + 'histogram_cdfp', + 'histogram_count', + 'histogram_percentile', + 'histogram_sum', + 'inf', + 'integral', + 'integrate', + 'integrate_fn', + 'iqr', + 'kronos_adjusted', + 'kronos_mean', + 'kronos_variance', + 'last', + 'linear_trend', + 'load', + 'load1', + 'log', + 'logarithmic_trend', + 'max', + 'median', + 'min', + 'mod', + 'moving_avg', + 'moving_percentile', + 'moving_sum', + 'non_negative_derivative', + 'percentile', + 'percentile_group_lines', + 'pow', + 'ramp', + 'random', + 'random01', + 'rate_to_delta', + 'relabel', + 'replace_nan', + 'round', + 'seasonal_adjusted', + 'seasonal_mean', + 'seasonal_variance', + 'series_avg', + 'series_count', + 'series_max', + 'series_min', + 'series_percentile', + 'series_sum', + 'shift', + 'sign', + 'single', + 'size', + 'sqr', + 'sqrt', + 'std', + 'sum', + 'summary_avg', + 'summary_count', + 'summary_last', + 'summary_max', + 'summary_min', + 'summary_sum', + 'tail', + 'take_if', + 'time_interval_begin', + 'time_interval_end', + 'timestamps', + 'to_fixed', + 'to_string', + 'to_vector', + 'top', + 'top_avg', + 'top_count', + 'top_last', + 'top_max', + 'top_min', + 'top_sum', + 'transform', + 'trunc', + ].map((item) => { + return { + label: item, + search: item, + value: {value: item}, + focus: {idx: ctx.idx + 1, key: 'key', offset: -1}, + }; + }), + }; + } + + return { + items: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'fetch', 'slice', 'sort'].map( + (item) => { + return { + label: item, + search: item, + value: {value: item}, + focus: {idx: ctx.idx + 1, key: 'key', offset: -1}, + }; + }, + ), + }; + } + default: { + return {items: [], options: {showEmptyState: false}}; + } + } +}; + +export const FullWidthSuggestionsTokenizedInput = ( + props: Omit, 'tokens' | 'onChange' | 'fields' | 'onSuggest'>, +) => { + const [tokens, setTokens] = React.useState([]); + + return ( + + ); +}; + +export const FullWidthSuggestionsTemplate: StoryFn> = (args) => { + return ; +}; diff --git a/src/components/TokenizedInput/__stories__/MultipleInputTemplate.tsx b/src/components/TokenizedInput/__stories__/MultipleInputTemplate.tsx new file mode 100644 index 00000000..4d07f836 --- /dev/null +++ b/src/components/TokenizedInput/__stories__/MultipleInputTemplate.tsx @@ -0,0 +1,22 @@ +import {Flex} from '@gravity-ui/uikit'; +import {StoryFn} from '@storybook/react'; + +import {AsyncSuggestsTokenizedInput} from './AsyncSuggestsTemplate'; +import {DefaultTokenizedInput} from './DefaultTemplate'; +import {TokenizedComponentType} from './types'; + +type TokenValue = {key: string; operator: string; value: string}; + +export const MultipleInputTemplate: StoryFn> = (args) => { + return ( + + + + + + ); +}; diff --git a/src/components/TokenizedInput/__stories__/SingleFieldTemplate.tsx b/src/components/TokenizedInput/__stories__/SingleFieldTemplate.tsx new file mode 100644 index 00000000..f8fe7c42 --- /dev/null +++ b/src/components/TokenizedInput/__stories__/SingleFieldTemplate.tsx @@ -0,0 +1,101 @@ +import * as React from 'react'; + +import {getUniqId} from '@gravity-ui/uikit'; +import {StoryFn} from '@storybook/react'; + +import {TokenizedInput} from '../TokenizedInput'; +import { + Token, + TokenField, + TokenizedInputData, + TokenizedSuggestionContext, + TokenizedSuggestions, +} from '../types'; + +import {TokenizedComponentType} from './types'; + +type TokenValue = {value: string}; + +const fields: TokenField[] = [ + { + key: 'value', + specialKeysActions: [ + { + key: (e) => e.key === ' ', + action: ({focus, onFocus, event}) => { + event.preventDefault(); + + onFocus({ + ...focus, + idx: focus.idx + 1, + key: 'value', + offset: -1, + }); + }, + }, + ], + }, +]; + +const onSuggest = ( + ctx: TokenizedSuggestionContext, +): TokenizedSuggestions => { + const values = ctx.tokens.map(({value}) => value.value); + const items = [ + 'Red', + 'Green', + 'Blue', + 'Yellow', + 'White', + 'Black', + 'Grey', + 'Pink', + 'Purple', + 'Orange', + 'Brown', + ].filter((c) => !values.includes(c)); + + return { + items: ['*', ...items].map((item, i) => { + const preselected = i === 1; + + return { + label: item, + search: item, + value: {value: item}, + focus: {idx: ctx.idx + 1, key: 'value', offset: -1}, + preselected, + }; + }), + }; +}; + +export const transformTokens = (tokens: TokenValue[]): Token[] => { + return tokens.map((t) => ({ + id: getUniqId(), + value: t, + isNew: false, + options: {readOnlyFields: ['value']}, + })); +}; + +export const SingleFieldTokenizedInput = ( + props: Omit, 'tokens' | 'onChange' | 'fields' | 'onSuggest'>, +) => { + const [tokens, setTokens] = React.useState([]); + + return ( + + ); +}; + +export const SingleFieldTemplate: StoryFn> = (args) => { + return ; +}; diff --git a/src/components/TokenizedInput/__stories__/TokenizedInput.stories.tsx b/src/components/TokenizedInput/__stories__/TokenizedInput.stories.tsx new file mode 100644 index 00000000..4a86cfe2 --- /dev/null +++ b/src/components/TokenizedInput/__stories__/TokenizedInput.stories.tsx @@ -0,0 +1,65 @@ +import {Meta} from '@storybook/react'; + +import {TokenizedInput} from '../TokenizedInput'; + +import {AsyncSuggestsTemplate} from './AsyncSuggestsTemplate'; +import {CustomPlaceholderTemplate} from './CustomPlaceholderTemplate'; +import {CustomRenderValueTemplate} from './CustomRenderValueTemplate'; +import {DebounceShowcaseTemplate} from './DebounceShowcaseTemplate'; +import {DefaultTemplate} from './DefaultTemplate'; +import {FullWidthSuggestionsTemplate} from './FullWidthSuggestionsTemplate'; +import {MultipleInputTemplate} from './MultipleInputTemplate'; +import {SingleFieldTemplate} from './SingleFieldTemplate'; +import {TokenizedComponentType} from './types'; + +const meta: Meta>> = { + title: 'Components/TokenizedInput', + component: TokenizedInput, + parameters: { + disableStrictMode: true, + }, +}; + +export default meta; + +export const Default = DefaultTemplate.bind({}); + +Default.args = { + isClearable: true, + isEditable: true, + placeholder: 'Enter a value', +}; + +export const CustomRenderValue = CustomRenderValueTemplate.bind({}); + +CustomRenderValue.args = { + isClearable: true, + isEditable: true, + placeholder: 'Enter a value', +}; + +export const SingleField = SingleFieldTemplate.bind({}); + +SingleField.args = { + isClearable: true, + isEditable: true, + placeholder: 'Choose colors', +}; + +export const AsyncSuggests = AsyncSuggestsTemplate.bind({}); + +AsyncSuggests.args = { + isClearable: true, + isEditable: true, + placeholder: 'Enter a value', + debounceDelay: 300, +}; + +export const MultipleInputs = MultipleInputTemplate.bind({}); +export const DebounceShowcaseInputs = DebounceShowcaseTemplate.bind({}); +export const FullWidthSuggestions = FullWidthSuggestionsTemplate.bind({}); + +export const CustomPlaceholder = CustomPlaceholderTemplate.bind({ + isClearable: true, + isEditable: true, +}); diff --git a/src/components/TokenizedInput/__stories__/types.ts b/src/components/TokenizedInput/__stories__/types.ts new file mode 100644 index 00000000..063e3948 --- /dev/null +++ b/src/components/TokenizedInput/__stories__/types.ts @@ -0,0 +1,4 @@ +import {TokenizedInput} from '../TokenizedInput'; +import {TokenValueBase} from '../types'; + +export type TokenizedComponentType = typeof TokenizedInput; diff --git a/src/components/TokenizedInput/components/Content/Content.tsx b/src/components/TokenizedInput/components/Content/Content.tsx new file mode 100644 index 00000000..1eadc9d3 --- /dev/null +++ b/src/components/TokenizedInput/components/Content/Content.tsx @@ -0,0 +1,11 @@ +import {useTokenizedInputComponents} from '../../context'; + +export function Content() { + const {Wrapper, TokenList} = useTokenizedInputComponents(); + + return ( + + + + ); +} diff --git a/src/components/TokenizedInput/components/Content/index.ts b/src/components/TokenizedInput/components/Content/index.ts new file mode 100644 index 00000000..04afba8e --- /dev/null +++ b/src/components/TokenizedInput/components/Content/index.ts @@ -0,0 +1 @@ +export {Content} from './Content'; diff --git a/src/components/TokenizedInput/components/Field/Field.tsx b/src/components/TokenizedInput/components/Field/Field.tsx new file mode 100644 index 00000000..4821271d --- /dev/null +++ b/src/components/TokenizedInput/components/Field/Field.tsx @@ -0,0 +1,80 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +import * as React from 'react'; + +import {FieldPopup} from './FieldPopup'; +import {useField} from './useField'; + +export type FieldProps = Omit< + React.DetailedHTMLProps, HTMLInputElement>, + 'ref' | 'value' | 'onChange' | 'onFocus' | 'onSelect' | 'onClick' | 'focused' +> & { + idx: number; + fieldKey: string; + isNew?: boolean; + value: string; + onChange: (idx: number, key: string, v: string) => void; + onFocus: (idx: number, key: string) => void; + error?: string; + selectOnClick?: boolean; + renderValue?: (inputState: ReturnType['state']) => React.ReactNode; +}; + +const FieldComponent = (props: FieldProps) => { + const fieldInfo = useField(props); + + const {inputProps} = fieldInfo; + const {Suggestions, showSuggestions} = fieldInfo.suggestions; + const {onBlurWrapper, onKeyDownWrapper} = fieldInfo.wrapper; + const { + idx, + fieldKey, + offset, + selection, + value, + placeholder, + hidden, + inputElement, + setInputElement, + readOnly, + classNames, + visibleValue, + } = fieldInfo.state; + + return ( +
+ + {props.renderValue?.(fieldInfo.state) || visibleValue} + + + {showSuggestions && ( + + )} +
+ ); +}; + +type TField = typeof FieldComponent & { + Popup: typeof FieldPopup; +}; + +export const Field = FieldComponent as TField; + +Field.Popup = FieldPopup; diff --git a/src/components/TokenizedInput/components/Field/FieldPopup.tsx b/src/components/TokenizedInput/components/Field/FieldPopup.tsx new file mode 100644 index 00000000..d72e3e5f --- /dev/null +++ b/src/components/TokenizedInput/components/Field/FieldPopup.tsx @@ -0,0 +1,22 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +import * as React from 'react'; + +import {Popup, type PopupProps} from '@gravity-ui/uikit'; + +import {b} from '../../constants'; + +export function FieldPopup({children, className, ...props}: React.PropsWithChildren) { + const onMouseDown = React.useCallback((e: React.MouseEvent) => { + e.preventDefault(); + }, []); + + if (!props.anchorElement) { + return null; + } + + return ( + +
{children}
+
+ ); +} diff --git a/src/components/TokenizedInput/components/Field/index.ts b/src/components/TokenizedInput/components/Field/index.ts new file mode 100644 index 00000000..f91e79a2 --- /dev/null +++ b/src/components/TokenizedInput/components/Field/index.ts @@ -0,0 +1,3 @@ +export {Field as FieldComponent} from './Field'; +export type {FieldProps as TokenizedInputFieldProps} from './Field'; +export {useField as useTokenizedInputField} from './useField'; diff --git a/src/components/TokenizedInput/components/Field/useField.ts b/src/components/TokenizedInput/components/Field/useField.ts new file mode 100644 index 00000000..945e74c0 --- /dev/null +++ b/src/components/TokenizedInput/components/Field/useField.ts @@ -0,0 +1,211 @@ +import * as React from 'react'; + +import {KeyCode, b} from '../../constants'; +import {useTokenizedInput, useTokenizedInputComponents} from '../../context'; +import {useApplyCallbackOnBlur} from '../../hooks'; + +import {FieldProps} from './Field'; + +type UseFieldOptions = FieldProps; + +export const useField = ({ + selectOnClick = false, + idx, + fieldKey, + value, + onFocus, + onChange, + placeholder, + readOnly, + isNew, + hidden, + error, + autoFocus, + ...inputProps +}: UseFieldOptions) => { + const {focusInfo, options} = useTokenizedInput(); + const {Suggestions} = useTokenizedInputComponents(); + + const {focus} = focusInfo.state; + + const isFocused = focus?.key === fieldKey && focus?.idx === idx; + const focusOffset = focus?.offset; + + const [inputElement, setInputElement] = React.useState(null); + const [hideSuggestions, setHideSuggestions] = React.useState(autoFocus); + const [offset, setOffset] = React.useState(undefined); + const [selection, setSelection] = React.useState<[number, number] | undefined>(undefined); + + const selectedOnClick = React.useRef(false); + const isMouseDown = React.useRef(false); + + const visibleValue = value || placeholder || ''; + + React.useEffect(() => { + if (!hideSuggestions && isFocused && inputElement) { + inputElement.focus(); + onFocus(idx, fieldKey); + + if (focusOffset !== undefined) { + let correctOffset = focusOffset; + + if (focusOffset < -1) { + correctOffset += inputElement.value.length + 1; + } + + inputElement?.setSelectionRange(correctOffset, correctOffset); + } + + setOffset((prev) => prev ?? inputElement.selectionStart ?? undefined); + setSelection([inputElement.selectionStart ?? 0, inputElement.selectionEnd ?? 0]); + } else { + setOffset(undefined); + setSelection(undefined); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fieldKey, focusOffset, idx, isFocused, inputElement]); + + const classNames = React.useMemo(() => { + const wrapper = b('field-wrapper', { + empty: !value, + focused: isFocused && !isNew, + hidden, + error: Boolean(error), + hoverable: !isNew && !readOnly, + }); + const visibleSpan = b( + 'field-visible-span', + { + placeholder: Boolean(!value && placeholder), + focused: isFocused && !isNew, + }, + inputProps.className, + ); + const input = b('field-input', inputProps.className); + + return {wrapper, visibleSpan, input}; + }, [error, hidden, inputProps.className, isFocused, isNew, placeholder, readOnly, value]); + + const showSuggestions = Boolean( + isFocused && + !hideSuggestions && + !readOnly && + offset !== undefined && + options.onSuggest && + !isMouseDown.current, + ); + + const onKeyDownWrapper = React.useCallback((e: React.KeyboardEvent) => { + if (e.key === KeyCode.Escape) { + setHideSuggestions((prev) => { + if (!prev) { + e.stopPropagation(); + return true; + } + return false; + }); + return; + } + if ((e.metaKey || e.ctrlKey) && e.code === 'KeyI') { + setHideSuggestions(false); + } + }, []); + + const resetField = React.useCallback(() => { + isMouseDown.current = false; + selectedOnClick.current = false; + setOffset(undefined); + }, []); + + const onBlurWrapper = useApplyCallbackOnBlur(resetField); + + const onMouseDownInput = React.useCallback(() => { + isMouseDown.current = true; + options.suggestionsInitialCall.setValue(true); + setOffset(inputElement?.selectionStart ?? undefined); + }, [inputElement?.selectionStart, options.suggestionsInitialCall]); + + const onMouseUpInput = React.useCallback(() => { + isMouseDown.current = false; + }, []); + + const onSelectInput = React.useCallback( + (e: React.SyntheticEvent) => { + if (!inputElement) { + return; + } + + const hasSelection = inputElement.selectionStart !== inputElement.selectionEnd; + const shouldSelectFullText = selectOnClick && !selectedOnClick.current && !hasSelection; + + if (e.nativeEvent.type === 'mouseup' && shouldSelectFullText) { + inputElement.setSelectionRange(0, -1); + selectedOnClick.current = true; + } + + setOffset(inputElement.selectionStart ?? undefined); + setSelection([inputElement.selectionStart ?? 0, inputElement.selectionEnd ?? 0]); + }, + [inputElement, selectOnClick], + ); + + const onClickInput = React.useCallback(() => { + setHideSuggestions(false); + }, []); + + const onChangeInput = React.useCallback( + (e: React.ChangeEvent) => { + onChange(idx, fieldKey, e.target.value); + setOffset(inputElement?.selectionStart ?? undefined); + setHideSuggestions(false); + }, + [fieldKey, idx, inputElement?.selectionStart, onChange], + ); + + const onFocusInput = React.useCallback(() => { + onFocus(idx, fieldKey); + }, [fieldKey, idx, onFocus]); + + return { + inputProps: { + ...inputProps, + onSelect: onSelectInput, + onClick: onClickInput, + onChange: onChangeInput, + onFocus: onFocusInput, + onMouseDown: onMouseDownInput, + onMouseUp: onMouseUpInput, + autoFocus, + } as React.DetailedHTMLProps, HTMLInputElement>, + wrapper: { + onBlurWrapper, + onKeyDownWrapper, + }, + state: { + idx, + fieldKey, + offset, + selection, + value, + placeholder, + isFocused, + hidden, + error, + isNew, + inputElement, + setInputElement, + readOnly, + classNames, + hideSuggestions, + visibleValue, + }, + callbacks: { + setOffset, + setHideSuggestions, + }, + suggestions: { + showSuggestions, + Suggestions, + }, + }; +}; diff --git a/src/components/TokenizedInput/components/Suggestions/Suggestions.tsx b/src/components/TokenizedInput/components/Suggestions/Suggestions.tsx new file mode 100644 index 00000000..4b4f74d9 --- /dev/null +++ b/src/components/TokenizedInput/components/Suggestions/Suggestions.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; + +import type {TokenValueBase} from '../../types'; +import {FieldPopup} from '../Field/FieldPopup'; + +import {SuggestionsList} from './SuggestionsList'; +import {useSuggestions} from './hooks'; +import type {SuggestionsData} from './types'; + +export type SuggestionsProps = SuggestionsData & { + inputElement: HTMLInputElement | null; + List?: typeof SuggestionsList; + withPopup?: boolean; +}; + +export function Suggestions({ + List = SuggestionsList, + withPopup = true, + ...props +}: SuggestionsProps) { + const suggestionsInfo = useSuggestions(props); + + const { + suggestions, + isLoading, + selected, + isPopupOpened, + inputElement, + popupWidth, + popupOffset, + fullWidthSuggestions, + } = suggestionsInfo.state; + const {onApplySuggestion} = suggestionsInfo.callbacks; + + const renderHint = () => { + if (!suggestions.hint || isLoading) { + return null; + } + + return suggestions.hint; + }; + + const renderList = () => { + return ( + + ); + }; + + if (withPopup) { + return ( + + {renderHint()} + {renderList()} + + ); + } + + return ( + + {renderHint()} + {renderList()} + + ); +} diff --git a/src/components/TokenizedInput/components/Suggestions/SuggestionsList.tsx b/src/components/TokenizedInput/components/Suggestions/SuggestionsList.tsx new file mode 100644 index 00000000..ef8147ba --- /dev/null +++ b/src/components/TokenizedInput/components/Suggestions/SuggestionsList.tsx @@ -0,0 +1,65 @@ +import * as React from 'react'; + +import {List} from '@gravity-ui/uikit'; + +import {b} from '../../constants'; +import i18n from '../../i18n'; +import type {TokenValueBase, TokenizedSuggestions, TokenizedSuggestionsItem} from '../../types'; + +export type SuggestionsListProps = { + selected: number; + isLoading: boolean; + suggestions: TokenizedSuggestions; + fullWidth?: boolean; + onApplySuggestion: (v: TokenizedSuggestionsItem) => void; +}; + +export function SuggestionsList({ + selected, + isLoading, + suggestions, + fullWidth, + onApplySuggestion, +}: SuggestionsListProps) { + const {items, options, currentWord} = suggestions; + const showEmptyState = options?.showEmptyState !== false; + const isEmpty = !isLoading && items.length === 0; + const currentText = currentWord?.value ?? ''; + + const EmptyPlaceholder = React.useMemo(() => { + if (isLoading || !showEmptyState) { + return null; + } + + return ( +

+ {i18n('suggestions.items_not_found', {text: currentText})} +

+ ); + }, [currentText, isLoading, showEmptyState]); + + if (isEmpty && !showEmptyState) { + return null; + } + + return ( +
+
{s.label}
} + activeItemIndex={selected} + filterable={false} + loading={isLoading} + emptyPlaceholder={EmptyPlaceholder} + onItemClick={onApplySuggestion} + virtualized={false} + /> +
+ ); +} diff --git a/src/components/TokenizedInput/components/Suggestions/hooks/index.ts b/src/components/TokenizedInput/components/Suggestions/hooks/index.ts new file mode 100644 index 00000000..facd3576 --- /dev/null +++ b/src/components/TokenizedInput/components/Suggestions/hooks/index.ts @@ -0,0 +1 @@ +export {useSuggestions} from './useSuggestions'; diff --git a/src/components/TokenizedInput/components/Suggestions/hooks/useSelectSuggestion.ts b/src/components/TokenizedInput/components/Suggestions/hooks/useSelectSuggestion.ts new file mode 100644 index 00000000..93b624c6 --- /dev/null +++ b/src/components/TokenizedInput/components/Suggestions/hooks/useSelectSuggestion.ts @@ -0,0 +1,68 @@ +import * as React from 'react'; + +import type {TokenValueBase, TokenizedSuggestions, TokenizedSuggestionsItem} from '../../../types'; +import {SuggestionsNavigationOptions} from '../types'; + +import {useSuggestionsNavigation} from './useSuggestionsNavigation'; + +type UseSelectSuggestionOptions = { + suggestions: TokenizedSuggestions; + inputElement: HTMLInputElement | null; + onApplySuggestion: (suggestion: TokenizedSuggestionsItem) => void; + onKeyDown?: (v: SuggestionsNavigationOptions) => boolean; +}; + +const getPreselected = (items: TokenizedSuggestionsItem[]) => { + const idx = items.findIndex((v) => v.preselected); + + return Math.max(idx, 0); +}; + +export const useSelectSuggestion = ({ + suggestions, + inputElement, + onApplySuggestion, + onKeyDown, +}: UseSelectSuggestionOptions) => { + const {items, currentWord} = suggestions; + + const [selected, setSelected] = React.useState(getPreselected(items)); + + React.useEffect(() => { + setSelected(getPreselected(items)); + }, [items]); + + const handleSelectNext = React.useCallback(() => { + setSelected((prev) => { + if (prev === items.length - 1) { + return 0; + } + return prev + 1; + }); + }, [items]); + + const handleSelectPrev = React.useCallback(() => { + setSelected((prev) => { + if (prev === 0) { + return items.length - 1; + } + return prev - 1; + }); + }, [items]); + + useSuggestionsNavigation({ + inputElement, + currentWord, + onKeyDown, + onSelectNext: handleSelectNext, + onSelectPrev: handleSelectPrev, + onApply: onApplySuggestion, + suggestion: items[selected], + }); + + return { + selected, + handleSelectNext, + handleSelectPrev, + }; +}; diff --git a/src/components/TokenizedInput/components/Suggestions/hooks/useSuggestions.ts b/src/components/TokenizedInput/components/Suggestions/hooks/useSuggestions.ts new file mode 100644 index 00000000..29463235 --- /dev/null +++ b/src/components/TokenizedInput/components/Suggestions/hooks/useSuggestions.ts @@ -0,0 +1,240 @@ +import * as React from 'react'; + +import {getUniqId} from '@gravity-ui/uikit'; +import debounce from 'lodash/debounce'; + +import {useTokenizedInput} from '../../../context'; +import type { + Token, + TokenValueBase, + TokenizedInputData, + TokenizedSuggestions, + TokenizedSuggestionsItem, +} from '../../../types'; +import {SuggestionsData} from '../types'; +import {fuzzySearch, sortSuggestions} from '../utils'; + +import {useSelectSuggestion} from './useSelectSuggestion'; +import {useSuggestionsPopupOptions} from './useSuggestionsPopupOptions'; + +type UseSuggestionsOptions = SuggestionsData & { + inputElement: HTMLInputElement | null; +}; + +export const useSuggestions = ({ + idx, + fieldKey, + value, + offset, + selection, + inputElement, + onKeyDown, +}: UseSuggestionsOptions) => { + const {inputInfo, focusInfo, options} = useTokenizedInput(); + + const {tokens} = inputInfo.state; + const {onChangeToken, onApplyChanges} = inputInfo.callbacks; + const {focus} = focusInfo.state; + const {onFocus} = focusInfo.callbacks; + const {onSuggest, debounceDelay, suggestionsInitialCall, fullWidthSuggestions} = options; + + const [suggestions, setSuggestions] = React.useState>({ + items: [], + }); + const [isLoadingData, setIsLoadingData] = React.useState(false); + + const initialLoadingRef = React.useRef(true); + const currentFnId = React.useRef(''); + const cancelledFns = React.useRef([]); + + const delay = React.useMemo( + () => (typeof debounceDelay === 'number' ? debounceDelay : debounceDelay[fieldKey]), + [debounceDelay, fieldKey], + ); + const handleGetSuggestions = React.useMemo( + () => + debounce( + async ( + args: SuggestionsData & { + tokens: Token[]; + onSuggest: TokenizedInputData['onSuggest']; + fnId: string; + }, + ) => { + if (!args.onSuggest) { + return; + } + + let isCancelled = false; + + setIsLoadingData(true); + setSuggestions({ + items: [], + currentWord: { + value: args.value, + offset: args.offset, + position: {start: 0, end: 0}, + }, + }); + try { + const response = await Promise.resolve( + args.onSuggest({ + idx: args.idx, + key: args.fieldKey, + value: args.value, + offset: args.offset, + selection: args.selection, + tokens: args.tokens, + }), + ); + + const { + items, + currentWord = { + value: args.value, + offset: args.offset, + position: {start: 0, end: 0}, + }, + } = response; + + const searchStr = + currentWord.value.slice(0, currentWord.offset).trim() || ''; + + if (cancelledFns.current.includes(args.fnId)) { + cancelledFns.current = cancelledFns.current.filter( + (fn) => fn !== args.fnId, + ); + isCancelled = true; + + return; + } + + if (!searchStr || response.options?.isFilterable === false) { + setSuggestions({ + ...response, + items: sortSuggestions(items), + currentWord, + }); + } else { + const filteredItems = fuzzySearch(items, searchStr); + + setSuggestions({ + ...response, + items: filteredItems, + currentWord, + }); + } + } finally { + if (!isCancelled) { + setIsLoadingData(false); + initialLoadingRef.current = false; + } + } + }, + delay, + ), + [delay], + ); + + React.useEffect(() => { + // Fixes the situation where the content of the popup goes beyond the viewport + if (suggestions.hint || suggestions.items.length) { + window.dispatchEvent(new Event('resize')); + } + }, [suggestions]); + + React.useEffect(() => { + handleGetSuggestions.cancel(); + if (currentFnId.current) { + cancelledFns.current.push(currentFnId.current); + } + + const fnId = getUniqId(); + currentFnId.current = fnId; + + handleGetSuggestions({ + onSuggest, + idx, + fieldKey, + value, + offset, + selection, + tokens, + fnId, + }); + + if (suggestionsInitialCall.value.current) { + handleGetSuggestions.flush(); + suggestionsInitialCall.setValue(false); + } + + return () => { + handleGetSuggestions.cancel(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [idx, fieldKey, value, offset]); + + const onApplySuggestion = React.useCallback( + (suggestion: TokenizedSuggestionsItem) => { + const focusIdx = focus?.idx ?? 0; + const token = tokens[focusIdx]; + const isNew = !token || token.isNew; + + onChangeToken(focusIdx, suggestion.value); + + if (suggestion.focus) { + onFocus({...suggestion.focus, ignoreChecks: true}); + } + + if (!isNew && suggestion.focus?.idx !== focusIdx) { + onApplyChanges(); + } + }, + [focus?.idx, onApplyChanges, onChangeToken, onFocus, tokens], + ); + + const {selected} = useSelectSuggestion({ + suggestions, + inputElement, + onApplySuggestion, + onKeyDown, + }); + + const {popupWidth, popupOffset} = useSuggestionsPopupOptions(inputElement); + + const isLoading = initialLoadingRef.current || isLoadingData; + const isPopupOpened = + isLoading || + Boolean(suggestions.items.length) || + Boolean(suggestions.hint) || + suggestions.options?.showEmptyState !== false; + + return React.useMemo( + () => ({ + state: { + suggestions, + isLoading, + selected, + isPopupOpened, + inputElement, + popupWidth, + popupOffset, + fullWidthSuggestions, + }, + callbacks: { + onApplySuggestion, + }, + }), + [ + fullWidthSuggestions, + inputElement, + isLoading, + isPopupOpened, + onApplySuggestion, + popupOffset, + popupWidth, + selected, + suggestions, + ], + ); +}; diff --git a/src/components/TokenizedInput/components/Suggestions/hooks/useSuggestionsNavigation.ts b/src/components/TokenizedInput/components/Suggestions/hooks/useSuggestionsNavigation.ts new file mode 100644 index 00000000..5a152a5c --- /dev/null +++ b/src/components/TokenizedInput/components/Suggestions/hooks/useSuggestionsNavigation.ts @@ -0,0 +1,90 @@ +import * as React from 'react'; + +import {KeyCode} from '../../../constants'; +import {useTokenizedInput} from '../../../context'; +import type {TokenValueBase} from '../../../types'; +import type {SuggestionsNavigationOptions} from '../types'; + +type UseSuggestionsNavigationOptions = Omit< + SuggestionsNavigationOptions, + 'event' | 'value' | 'focus' +> & { + inputElement: HTMLInputElement | null; + onKeyDown?: (v: SuggestionsNavigationOptions) => boolean; +}; + +export const useSuggestionsNavigation = ({ + inputElement, + onKeyDown, + onSelectNext, + onSelectPrev, + onApply, + suggestion, + currentWord, +}: UseSuggestionsNavigationOptions) => { + const {focusInfo} = useTokenizedInput(); + const {focus} = focusInfo.state; + + React.useEffect(() => { + const handleNavigation = (e: KeyboardEvent) => { + const preventOtherKeys = + onKeyDown?.({ + event: e, + suggestion, + value: inputElement?.value || '', + focus, + onApply, + onSelectNext, + onSelectPrev, + currentWord, + }) ?? false; + + if (preventOtherKeys) { + return; + } + + const next = (e.ctrlKey && e.code === 'KeyN') || e.key === KeyCode.ArrowDown; + const prev = (e.ctrlKey && e.code === 'KeyP') || e.key === KeyCode.ArrowUp; + const select = suggestion && !e.metaKey && !e.ctrlKey && e.key === KeyCode.Enter; + + switch (true) { + case next: { + e.preventDefault(); + e.stopPropagation(); + onSelectNext(); + break; + } + case prev: { + e.stopPropagation(); + e.preventDefault(); + onSelectPrev(); + break; + } + case select: { + e.preventDefault(); + e.stopPropagation(); + onApply(suggestion); + break; + } + default: { + break; + } + } + }; + + inputElement?.addEventListener('keydown', handleNavigation); + + return () => { + inputElement?.removeEventListener('keydown', handleNavigation); + }; + }, [ + onSelectNext, + onSelectPrev, + onApply, + suggestion, + onKeyDown, + currentWord, + focus, + inputElement, + ]); +}; diff --git a/src/components/TokenizedInput/components/Suggestions/hooks/useSuggestionsPopupOptions.ts b/src/components/TokenizedInput/components/Suggestions/hooks/useSuggestionsPopupOptions.ts new file mode 100644 index 00000000..e26b5d00 --- /dev/null +++ b/src/components/TokenizedInput/components/Suggestions/hooks/useSuggestionsPopupOptions.ts @@ -0,0 +1,45 @@ +import * as React from 'react'; + +import type {PopupOffset} from '@gravity-ui/uikit'; + +import {useTokenizedInput} from '../../../context'; + +export const useSuggestionsPopupOptions = (inputElement: HTMLInputElement | null) => { + const {inputInfo, options} = useTokenizedInput(); + + const {wrapperRef} = inputInfo.state; + const {fullWidthSuggestions} = options; + + const [popupWidth, setPopupWidth] = React.useState( + fullWidthSuggestions ? wrapperRef.current?.offsetWidth : undefined, + ); + + React.useEffect(() => { + if (!fullWidthSuggestions || !wrapperRef.current) { + return () => {}; + } + + const resizeObserver = new ResizeObserver((resizes) => { + setPopupWidth(resizes[0].borderBoxSize[0].inlineSize); + }); + + resizeObserver.observe(wrapperRef.current); + + return () => { + resizeObserver.disconnect(); + }; + }, [wrapperRef, fullWidthSuggestions]); + + const popupOffset = React.useMemo(() => { + if (!fullWidthSuggestions) { + return 0; + } + + const inputX = inputElement?.getBoundingClientRect()?.x ?? 0; + const wrapperX = wrapperRef.current?.getBoundingClientRect()?.x ?? 0; + + return {mainAxis: 0, crossAxis: wrapperX - inputX}; + }, [fullWidthSuggestions, inputElement, wrapperRef]); + + return {popupWidth, popupOffset}; +}; diff --git a/src/components/TokenizedInput/components/Suggestions/index.ts b/src/components/TokenizedInput/components/Suggestions/index.ts new file mode 100644 index 00000000..e1d67855 --- /dev/null +++ b/src/components/TokenizedInput/components/Suggestions/index.ts @@ -0,0 +1,6 @@ +export {Suggestions as SuggestionsComponent} from './Suggestions'; +export type {SuggestionsProps as TokenizedInputSuggestionsProps} from './Suggestions'; +export type {SuggestionsListProps} from './SuggestionsList'; +export type {SuggestionsNavigationOptions} from './types'; +export {useSuggestions as useTokenizedInputSuggestions} from './hooks'; +export {fuzzySearch} from './utils'; diff --git a/src/components/TokenizedInput/components/Suggestions/types.ts b/src/components/TokenizedInput/components/Suggestions/types.ts new file mode 100644 index 00000000..591dce8b --- /dev/null +++ b/src/components/TokenizedInput/components/Suggestions/types.ts @@ -0,0 +1,29 @@ +import type {TokenFocus, TokenValueBase, TokenizedSuggestionsItem} from '../../types'; + +export type SuggestionsNavigationOptions = { + value: string; + focus?: TokenFocus; + suggestion: TokenizedSuggestionsItem; + event: KeyboardEvent; + currentWord?: { + value: string; + offset: number; + position: { + start: number; + end: number; + }; + }; + onSelectPrev: () => void; + onSelectNext: () => void; + onApply: (suggestion: TokenizedSuggestionsItem) => void; +}; + +export interface SuggestionsData { + idx: number; + fieldKey: keyof T; + value: string; + offset: number; + selection?: [number, number]; + className?: string; + onKeyDown?: (v: SuggestionsNavigationOptions) => boolean; +} diff --git a/src/components/TokenizedInput/components/Suggestions/utils.test.ts b/src/components/TokenizedInput/components/Suggestions/utils.test.ts new file mode 100644 index 00000000..5ff39469 --- /dev/null +++ b/src/components/TokenizedInput/components/Suggestions/utils.test.ts @@ -0,0 +1,76 @@ +import {type SearchValue, fuzzySearch} from './utils'; + +const itemsMapper = (items: string[]): SearchValue[] => { + return items.map((search) => ({search})); +}; + +const itemsSortReversedMapper = (items: SearchValue[]): SearchValue[] => { + return items.map(({search}, i) => ({search, sort: items.length - i})); +}; + +describe('fuzzySearch', () => { + it('should return all items if search is empty', () => { + const items = itemsMapper(['apple', 'banana', 'grape']); + const search = ''; + const result = fuzzySearch(items, search); + expect(result).toEqual(items); + }); + + it('should return items that include the search string regardless of case', () => { + const items = itemsMapper(['Apple', 'Banana', 'Grape', 'Pineapple']); + const search = 'aPpLe'; + const result = fuzzySearch(items, search); + expect(result).toEqual(itemsMapper(['Apple', 'Pineapple'])); + }); + + it('should return an empty array if no items match the search', () => { + const items = itemsMapper(['apple', 'banana', 'grape']); + const search = 'orange'; + const result = fuzzySearch(items, search); + expect(result).toEqual([]); + }); + + it('should return the subset of items that match the search', () => { + const items = itemsMapper(['apple', 'banana', 'grape', 'pineapple']); + const search = 'ap'; + const result = fuzzySearch(items, search); + expect(result).toEqual(itemsMapper(['apple', 'grape', 'pineapple'])); + }); + + it('should handle search with skipped letters', () => { + const items = itemsMapper(['apple', 'application', 'apparatus']); + const search = 'apl'; + const result = fuzzySearch(items, search); + expect(result).toEqual(itemsMapper(['apple', 'application'])); + }); + + it('should handle short search (1 character), sorted by start character', () => { + const items = itemsMapper(['apple', 'banana', 'grape', 'apricot']); + const search = 'a'; + const result = fuzzySearch(items, search); + expect(result).toEqual(itemsMapper(['apple', 'apricot', 'banana', 'grape'])); + }); + + it('should handle search with spaces', () => { + const items = itemsMapper(['apple pie', 'banana bread', 'grape juice']); + const search = 'pie'; + const result = fuzzySearch(items, search); + expect(result).toEqual(itemsMapper(['apple pie', 'grape juice'])); + }); + + it('should handle search with mixed case', () => { + const items = itemsMapper(['app', 'Apple', 'Banana', 'Grape', 'Apricot']); + const search = 'aPp'; + const result = fuzzySearch(items, search); + expect(result).toEqual(itemsMapper(['app', 'Apple'])); + }); + + it('should handle search with mixed case, with sort reversed', () => { + const items = itemsSortReversedMapper( + itemsMapper(['app', 'Apple', 'Banana', 'Grape', 'Apricot']), + ); + const search = 'aP'; + const result = fuzzySearch(items, search); + expect(result).toEqual(items.filter((i) => i.search !== 'Banana').reverse()); + }); +}); diff --git a/src/components/TokenizedInput/components/Suggestions/utils.ts b/src/components/TokenizedInput/components/Suggestions/utils.ts new file mode 100644 index 00000000..04c4c0a6 --- /dev/null +++ b/src/components/TokenizedInput/components/Suggestions/utils.ts @@ -0,0 +1,72 @@ +import FuzzySearch from 'fuzzy-search'; + +const K_START = 8; + +export type SearchValue = { + search: string; + sort?: number; +}; + +export const fuzzySearchScore = (item: T, search: string) => { + let start = item.search.length; + let maxSubstr = 1; + + for (let i = search.length; i > 0; i--) { + const substr = search.slice(0, i); + const idx = item.search.indexOf(substr); + const idxLower = item.search.toLowerCase().indexOf(substr.toLowerCase()); + + if (idx !== -1) { + start = idx; + maxSubstr = substr.length; + break; + } + if (idxLower !== -1) { + start = idxLower; + maxSubstr = substr.length; + break; + } + } + + const score = FuzzySearch.isMatch(item.search, search, false); + + if (!score) { + return 0; + } + + const tunedScore = Math.round(score + (start / maxSubstr) * K_START); + + return tunedScore || 0.00001; +}; + +export const sortSuggestions = (items: T[]) => { + const itemsWithSort = items.filter((item) => item.sort !== undefined); + const itemsWithoutSort = items.filter((item) => item.sort === undefined); + + return [...itemsWithSort.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0)), ...itemsWithoutSort]; +}; + +export const fuzzySearch = (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..f921d101 --- /dev/null +++ b/src/components/TokenizedInput/components/Tokens/Token/RegularToken.tsx @@ -0,0 +1,46 @@ +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..d6bd235d --- /dev/null +++ b/src/components/TokenizedInput/components/Wrapper/Wrapper.tsx @@ -0,0 +1,24 @@ +/* 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..83e6243d --- /dev/null +++ b/src/components/TokenizedInput/components/Wrapper/hooks/useKeyDownHandler.ts @@ -0,0 +1,470 @@ +import * as React from 'react'; + +import {KeyCode} from '../../../constants'; +import {useTokenizedInput} from '../../../context'; +import type {Token, TokenValueBase} from '../../../types'; + +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 (e.metaKey || e.ctrlKey) { + return jumpToNeighborToken(e); + } + if (e.altKey) { + 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 (e.ctrlKey || e.metaKey) { + 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 (e.ctrlKey || e.metaKey) { + 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 ( + (e.metaKey && e.shiftKey && e.code === 'KeyZ') || + (e.ctrlKey && e.code === 'KeyY') + ) { + e.preventDefault(); + const newTokens = onRedo(); + focusLastToken(newTokens); + + return true; + } + if ((e.metaKey || e.ctrlKey) && e.code === 'KeyZ') { + 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'; From 873bfd1f6d0d60970754e97d398b8ac77495da39 Mon Sep 17 00:00:00 2001 From: Alexey Gryzin <> Date: Mon, 23 Mar 2026 11:15:53 +0300 Subject: [PATCH 2/6] fix: removed useless useeffect --- .../components/Suggestions/hooks/useSuggestions.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/components/TokenizedInput/components/Suggestions/hooks/useSuggestions.ts b/src/components/TokenizedInput/components/Suggestions/hooks/useSuggestions.ts index 29463235..6dc430b8 100644 --- a/src/components/TokenizedInput/components/Suggestions/hooks/useSuggestions.ts +++ b/src/components/TokenizedInput/components/Suggestions/hooks/useSuggestions.ts @@ -136,13 +136,6 @@ export const useSuggestions = ({ [delay], ); - React.useEffect(() => { - // Fixes the situation where the content of the popup goes beyond the viewport - if (suggestions.hint || suggestions.items.length) { - window.dispatchEvent(new Event('resize')); - } - }, [suggestions]); - React.useEffect(() => { handleGetSuggestions.cancel(); if (currentFnId.current) { From 4f788298846f0aae240ed084154922a97174ab78 Mon Sep 17 00:00:00 2001 From: Alexey Gryzin <> Date: Mon, 23 Mar 2026 11:47:48 +0300 Subject: [PATCH 3/6] feat: added specific shortcuts for mac/windows+linux --- src/components/TokenizedInput/README.md | 13 ++--- .../Wrapper/hooks/useKeyDownHandler.ts | 49 +++++++++++++++---- 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/src/components/TokenizedInput/README.md b/src/components/TokenizedInput/README.md index 5f818436..9ee524be 100644 --- a/src/components/TokenizedInput/README.md +++ b/src/components/TokenizedInput/README.md @@ -14,11 +14,12 @@ This component is for writing queries/filters and working with them as tokens. H ### Hotkeys -- `Cmd/Ctrl + Arrow` — move between tokens -- `Option/Alt + Arrow` — move between token fields -- `Cmd/Ctrl + Delete` — delete the current token -- `Cmd/Ctrl + Z` — undo -- `Cmd + Shift + Z / Ctrl + Y` — redo +- `Cmd + Arrow` (Mac) / `Ctrl + Alt + Arrow` (Win/Linux) — move between tokens +- `Option + Arrow` (Mac) / `Ctrl + Arrow` (Win/Linux) — move between token fields +- `Cmd + Backspace` (Mac) / `Ctrl + Alt + Backspace` (Win/Linux) — delete the current token +- `Cmd + Z` (Mac) / `Ctrl + Z` (Win/Linux) — undo +- `Cmd + Shift + Z` (Mac) / `Ctrl + Y` or `Ctrl + Shift + Z` (Win/Linux) — redo - `Escape` — close the suggestions menu; press again to remove focus -- `Cmd/Ctrl + I` — open the suggestions menu +- `Cmd + I` (Mac) / `Ctrl + I` (Win/Linux) — open the suggestions menu +- `Cmd + Enter` (Mac) / `Ctrl + Enter` (Win/Linux) — finish the current token and go to the next (when the suggestions menu is closed) - `Enter` — select a suggestion / finish the current token and go to the next (when the suggestions menu is closed) diff --git a/src/components/TokenizedInput/components/Wrapper/hooks/useKeyDownHandler.ts b/src/components/TokenizedInput/components/Wrapper/hooks/useKeyDownHandler.ts index 83e6243d..5e4a21d7 100644 --- a/src/components/TokenizedInput/components/Wrapper/hooks/useKeyDownHandler.ts +++ b/src/components/TokenizedInput/components/Wrapper/hooks/useKeyDownHandler.ts @@ -4,6 +4,40 @@ 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(); @@ -203,10 +237,10 @@ export const useKeyDownHandler = () => { const navigationHandler = React.useCallback( (e: React.KeyboardEvent) => { if (!e.shiftKey) { - if (e.metaKey || e.ctrlKey) { + if (shortcuts.isTokenModifier(e)) { return jumpToNeighborToken(e); } - if (e.altKey) { + if (shortcuts.isFieldModifier(e)) { return jumpToNeighborField(e); } if (!checkKey(e, KeyCode.Tab)) { @@ -227,7 +261,7 @@ export const useKeyDownHandler = () => { } if (checkKey(e, KeyCode.Backspace)) { - if (e.ctrlKey || e.metaKey) { + if (shortcuts.isTokenModifier(e)) { e.preventDefault(); let idx = focus.idx; @@ -294,7 +328,7 @@ export const useKeyDownHandler = () => { e.preventDefault(); onApplyChanges(); - if (e.ctrlKey || e.metaKey) { + if (shortcuts.isApplyModifier(e)) { handler(); return true; @@ -383,17 +417,14 @@ export const useKeyDownHandler = () => { }); }; - if ( - (e.metaKey && e.shiftKey && e.code === 'KeyZ') || - (e.ctrlKey && e.code === 'KeyY') - ) { + if (shortcuts.isRedo(e)) { e.preventDefault(); const newTokens = onRedo(); focusLastToken(newTokens); return true; } - if ((e.metaKey || e.ctrlKey) && e.code === 'KeyZ') { + if (shortcuts.isUndo(e)) { e.preventDefault(); const newTokens = onUndo(); focusLastToken(newTokens); From 0a1572b4c53ca84e4b96c268007c82b68661f32d Mon Sep 17 00:00:00 2001 From: Alexey Gryzin <> Date: Mon, 23 Mar 2026 12:10:58 +0300 Subject: [PATCH 4/6] chore: types --- package-lock.json | 8 ++++++++ package.json | 1 + 2 files changed, 9 insertions(+) diff --git a/package-lock.json b/package-lock.json index d5552626..7ee556ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", + "@types/fuzzy-search": "^2.1.5", "@types/jest": "^29.5.14", "@types/lodash": "^4.17.14", "@types/react": "^18.3.18", @@ -6292,6 +6293,13 @@ "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", "dev": true }, + "node_modules/@types/fuzzy-search": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/fuzzy-search/-/fuzzy-search-2.1.5.tgz", + "integrity": "sha512-Yw8OsjhVKbKw83LMDOZ9RXc+N+um48DmZYMrz7QChpHkQuygsc5O40oCL7SfvWgpaaviCx2TbNXYUBwhMtBH5w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", diff --git a/package.json b/package.json index db89749b..02866d23 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", + "@types/fuzzy-search": "^2.1.5", "@types/jest": "^29.5.14", "@types/lodash": "^4.17.14", "@types/react": "^18.3.18", From 0250c14f32d98237de3ac3fc7dc63bea7fa5838e Mon Sep 17 00:00:00 2001 From: Alexey Gryzin <> Date: Mon, 23 Mar 2026 12:14:30 +0300 Subject: [PATCH 5/6] chore: lint --- .../TokenizedInput/__stories__/DebounceShowcaseTemplate.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/TokenizedInput/__stories__/DebounceShowcaseTemplate.tsx b/src/components/TokenizedInput/__stories__/DebounceShowcaseTemplate.tsx index 25994b25..b4498f60 100644 --- a/src/components/TokenizedInput/__stories__/DebounceShowcaseTemplate.tsx +++ b/src/components/TokenizedInput/__stories__/DebounceShowcaseTemplate.tsx @@ -1,5 +1,3 @@ -/* eslint-disable react/no-unescaped-entities */ - import {Flex, Text} from '@gravity-ui/uikit'; import {StoryFn} from '@storybook/react'; From 6afcc91a371abfb71f2591a9f16d89b801c31991 Mon Sep 17 00:00:00 2001 From: Alexey Gryzin <> Date: Mon, 23 Mar 2026 13:16:18 +0300 Subject: [PATCH 6/6] fix: fixed storybook a11y tests --- src/components/TokenizedInput/components/Field/Field.tsx | 1 + .../components/Tokens/Token/RegularToken.tsx | 1 + .../TokenizedInput/components/Wrapper/Wrapper.tsx | 7 ++++++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/TokenizedInput/components/Field/Field.tsx b/src/components/TokenizedInput/components/Field/Field.tsx index 4821271d..c32c1610 100644 --- a/src/components/TokenizedInput/components/Field/Field.tsx +++ b/src/components/TokenizedInput/components/Field/Field.tsx @@ -55,6 +55,7 @@ const FieldComponent = (props: FieldProps) => { autoComplete="off" readOnly={readOnly} tabIndex={hidden ? -1 : 0} + aria-label={`token ${idx} field ${fieldKey} input`} formNoValidate /> {showSuggestions && ( diff --git a/src/components/TokenizedInput/components/Tokens/Token/RegularToken.tsx b/src/components/TokenizedInput/components/Tokens/Token/RegularToken.tsx index f921d101..7d71bd65 100644 --- a/src/components/TokenizedInput/components/Tokens/Token/RegularToken.tsx +++ b/src/components/TokenizedInput/components/Tokens/Token/RegularToken.tsx @@ -37,6 +37,7 @@ export function RegularToken({idx}: RegularTokenProps) { onClick={onRemove} tabIndex={-1} disabled={!isEditable} + aria-label={`remove-token-button-${idx}`} > diff --git a/src/components/TokenizedInput/components/Wrapper/Wrapper.tsx b/src/components/TokenizedInput/components/Wrapper/Wrapper.tsx index d6bd235d..c319dc61 100644 --- a/src/components/TokenizedInput/components/Wrapper/Wrapper.tsx +++ b/src/components/TokenizedInput/components/Wrapper/Wrapper.tsx @@ -15,7 +15,12 @@ export function Wrapper({children}: React.PropsWithChildren) {
{children} {isClearable && ( - )}