diff --git a/CODEOWNERS b/CODEOWNERS index f2da38bd..84fb5184 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -14,4 +14,5 @@ /src/components/Stories @darkgenius /src/components/ConfirmDialog @kseniya57 /src/components/Gallery @kseniya57 +/src/components/TokenizedInput @feelsbadmans /src/hooks/useGallery @kseniya57 diff --git a/src/components/TokenizedInput/README.md b/src/components/TokenizedInput/README.md new file mode 100644 index 00000000..eb6d2d17 --- /dev/null +++ b/src/components/TokenizedInput/README.md @@ -0,0 +1,251 @@ +## 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). + +### API Reference + +| Prop | Type | Default | Description | +| :---------------------- | :------------------------------------------------------------------------------------------------------------------- | :-------------- | :------------------------------------------------------------- | +| `tokens` | `T[]` | - | Array of token values. | +| `fields` | `TokenizedInputTokenField[]` | - | Field definitions; order matches display order. | +| `onChange` | `(newTokens: T[]) => void` | - | Token list change handler. | +| `defaultTokens` | `T[]` | `[]` | Defaults applied on full clear. | +| `transformTokens` | `(tokens: T[]) => TokenizedInputToken[]` | - | Maps raw tokens to internal token shape. | +| `validateToken` | `(token: T) => Partial> \| undefined \| false` | - | Validates a token. | +| `formatToken` | `(token: T) => T` | - | Formats a token value before saving. | +| `placeholder` | `string \| TokenizedInputTokenPlaceholderGeneratorFn` | - | Placeholder for the new token. | +| `isEditable` | `boolean` | `true` | Whether editing is allowed. | +| `isClearable` | `boolean` | `true` | Whether full clear is allowed. | +| `debounceDelay` | `number \| Record` | `150` | Suggestions debounce delay; per-field overrides are supported. | +| `debounceFlushStrategy` | `'focus-input' \| 'focus-field'` | `'focus-field'` | When debounce flushes. | +| `autoFocus` | `boolean` | `false` | Autofocus the new token. | +| `onSuggest` | `(ctx: TokenizedInputSuggestionContext) => TokenizedInputSuggestions \| Promise>` | - | Fetches suggestions. | +| `filterSuggestions` | `(items: TokenizedInputSuggestionsItem[], search: string) => TokenizedInputSuggestionsItem[]` | - | Custom function to filter suggestions based on search string. | +| `fullWidthSuggestions` | `boolean` | `false` | Render suggestions full width below the input. | +| `onKeyDown` | `(v: TokenizedInputTokenOnKeyDownOptions) => boolean` | - | Keydown handler; return true to stop further handling. | +| `onFocus` | `() => void` | - | onFocus callback. | +| `onBlur` | `() => void` | - | onBlur callback. | +| `shouldAllowBlur` | `(e: React.FocusEvent) => boolean` | `() => true` | Return true to allow blur, false to prevent it. | + +### Usage Examples + +#### 1. Basic Key-Value Input + +The most common use case is a query builder where each token consists of a `key`, `operator`, and `value`. + +```tsx +import {TokenizedInput} from '@gravity-ui/components'; + +type MyToken = { + key: string; + operator: string; + value: string; +}; + +const fields = [ + {key: 'key', className: 'my-key-field'}, + {key: 'operator', className: 'my-operator-field'}, + {key: 'value', className: 'my-value-field'}, +]; + +function App() { + const [tokens, setTokens] = React.useState([]); + + return ( + + tokens={tokens} + fields={fields} + onChange={setTokens} + onSuggest={async ({key, value}) => { + // Return suggestions based on the current field + if (key === 'key') { + return { + items: [ + { + label: 'Status', + search: 'Status', + value: {key: 'Status'}, + focus: {idx: 0, key: 'operator', offset: -1}, + }, + { + label: 'User', + search: 'User', + value: {key: 'User'}, + focus: {idx: 0, key: 'operator', offset: -1}, + }, + ], + }; + } + return {items: []}; + }} + /> + ); +} +``` + +#### 2. Single Field (Tags) Input + +You can use `TokenizedInput` as a simple tags input by defining only one field and using `transformTokens` to make existing tokens read-only. + +```tsx +import {getUniqId} from '@gravity-ui/uikit'; +import {TokenizedInput, TokenizedInputToken} from '@gravity-ui/components'; + +type TagToken = {value: string}; + +const fields = [ + { + key: 'value', + specialKeysActions: [ + { + // Create a new token when pressing Space + key: (e) => e.key === ' ', + action: ({focus, onFocus, event}) => { + event.preventDefault(); + onFocus({...focus, idx: focus.idx + 1, key: 'value', offset: -1}); + }, + }, + ], + }, +]; + +// Make existing tokens read-only so they act like solid blocks (tags) +const transformTokens = (tokens: TagToken[]): TokenizedInputToken[] => { + return tokens.map((t) => ({ + id: getUniqId(), + value: t, + kind: 'regular', + options: {readOnlyFields: ['value']}, // Prevents editing the text inside the tag + })); +}; + +function TagsInput() { + const [tokens, setTokens] = React.useState([]); + + return ( + + ); +} +``` + +#### 3. Dynamic Placeholders + +You can provide a function to the `placeholder` prop to generate context-aware placeholders based on the token's current state. + +```tsx +const placeholder = React.useCallback((tokenType, token, idx) => { + // Show a specific placeholder when the user is about to type a value for the "message" key + if (token.key === 'message' && idx === 2) { + return 'Enter a string'; + } + // Show a general placeholder for the very first empty token + if (tokenType === 'new' && idx === 0) { + return 'Enter a value'; + } + return undefined; +}, []); + +; +``` + +### Composition Pattern + +The `TokenizedInput` component is highly modular and built using a composition of smaller sub-components. You can override any of its sub-components by passing a custom component directly to the corresponding prop. + +The available sub-components are: + +- `Wrapper`: The outermost container that handles global key presses and blur events. +- `TokenList`: Renders the list of tokens. +- `Token`: Renders an individual token (either a `RegularToken` or a `NewToken`). +- `Field`: Renders the actual input field inside a token. +- `Suggestions`: Renders the suggestions popup. + +#### Overriding Sub-components + +If you want to customize the rendering of the input values (e.g., to add syntax highlighting or custom formatting), you can override the `Field` component to use the `renderValue` prop. + +```tsx +import {TokenizedInput, TokenizedInputFieldProps} from '@gravity-ui/components'; + +// 1. Define your custom render logic +const renderValue: TokenizedInputFieldProps['renderValue'] = ({ + fieldKey, + isFocused, + isNew, + visibleValue, +}) => { + // Don't format while typing + if (isNew || isFocused) { + return visibleValue; + } + + // Apply custom formatting based on the field type + if (fieldKey === 'key') { + return {visibleValue}; + } + if (fieldKey === 'value') { + return {visibleValue}; + } + + return visibleValue; +}; + +// 2. Create a custom Field component that wraps the original Field +const CustomField = (props: TokenizedInputFieldProps) => { + return ; +}; + +// 3. Pass it to the Field prop +function App() { + return ( + + ); +} +``` + +#### Context Hooks + +When building fully custom sub-components, you can use the provided context hooks to access the internal state and callbacks of the `TokenizedInput`: + +- `useTokenizedInputContext()` — Input state and callbacks (`tokens`, `fields`, `onChangeToken`, `onRemoveToken`, etc.) +- `useTokenizedInputFocusContext()` — Focus state and callbacks (`focus`, `onFocus`, `onBlur`, `getFocusRules`, etc.) +- `useTokenizedInputOptionsContext()` — Extra options from props (`onSuggest`, `debounceDelay`, `shouldAllowBlur`, etc.) +- `useTokenizedInput()` — Returns all three contexts above combined (`inputInfo`, `focusInfo`, `options`). +- `useTokenizedInputComponents()` — Access to the current sub-components (useful if your custom component needs to render the default `Field` or `Token`). + +For convenience, there are also specific hooks for each part of the component that you can use as a starting point: `useTokenizedInputWrapper`, `useTokenizedInputList`, `useTokenizedInputNewToken`, `useTokenizedInputRegularToken`, `useTokenizedInputField`, `useTokenizedInputSuggestions`. + +### Hotkeys + +#### Mac + +- `Cmd + Arrow` — move between tokens +- `Option + Arrow` — move between token fields +- `Cmd + Backspace` — delete the current token +- `Cmd + Z` — undo +- `Cmd + Shift + Z` — redo +- `Cmd + I` — open the suggestions menu +- `Cmd + Enter` — finish the current token and go to the next (when the suggestions menu is closed) + +#### Windows / Linux + +- `Ctrl + Alt + Arrow` — move between tokens +- `Ctrl + Arrow` — move between token fields +- `Ctrl + Alt + Backspace` — delete the current token +- `Ctrl + Z` — undo +- `Ctrl + Y` or `Ctrl + Shift + Z` — redo +- `Ctrl + I` — open the suggestions menu +- `Ctrl + Enter` — finish the current token and go to the next (when the suggestions menu is closed) + +#### General + +- `Escape` — close the suggestions menu; press again to remove focus +- `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..bbb350ce --- /dev/null +++ b/src/components/TokenizedInput/__stories__/AsyncSuggestsTemplate.tsx @@ -0,0 +1,283 @@ +import * as React from 'react'; + +import {StoryFn} from '@storybook/react'; + +import {TokenizedInput} from '../TokenizedInput'; +import { + TokenField, + TokenizedInputData, + TokenizedSuggestionContext, + TokenizedSuggestions, + TokenizedSuggestionsItem, +} from '../types'; +import {tokenizedInputUtils} from '../utils'; + +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..aa3a7156 --- /dev/null +++ b/src/components/TokenizedInput/__stories__/CustomPlaceholderTemplate.tsx @@ -0,0 +1,183 @@ +import * as React from 'react'; + +import {StoryFn} from '@storybook/react'; + +import {TokenizedInput} from '../TokenizedInput'; +import { + TokenField, + TokenPlaceholderGeneratorFn, + TokenizedInputData, + TokenizedSuggestionContext, + TokenizedSuggestions, + TokenizedSuggestionsItem, +} from '../types'; +import {tokenizedInputUtils} from '../utils'; + +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..b4498f60 --- /dev/null +++ b/src/components/TokenizedInput/__stories__/DebounceShowcaseTemplate.tsx @@ -0,0 +1,51 @@ +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..5260133b --- /dev/null +++ b/src/components/TokenizedInput/__stories__/DefaultTemplate.tsx @@ -0,0 +1,357 @@ +import * as React from 'react'; + +import {StoryFn} from '@storybook/react'; + +import {TokenizedInput} from '../TokenizedInput'; +import { + TokenField, + TokenizedInputData, + TokenizedSuggestionContext, + TokenizedSuggestions, + TokenizedSuggestionsItem, +} from '../types'; +import {tokenizedInputUtils} from '../utils'; + +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..88db1522 --- /dev/null +++ b/src/components/TokenizedInput/__stories__/FullWidthSuggestionsTemplate.tsx @@ -0,0 +1,358 @@ +import * as React from 'react'; + +import {StoryFn} from '@storybook/react'; + +import {TokenizedInput} from '../TokenizedInput'; +import { + TokenField, + TokenizedInputData, + TokenizedSuggestionContext, + TokenizedSuggestions, + TokenizedSuggestionsItem, +} from '../types'; +import {tokenizedInputUtils} from '../utils'; + +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..6c2c2e75 --- /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.filter((v) => v.kind === 'regular').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, + kind: 'regular', + 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..72d05883 --- /dev/null +++ b/src/components/TokenizedInput/components/Field/Field.tsx @@ -0,0 +1,83 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +import * as React from 'react'; + +import i18n from '../../i18n'; + +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..99ed14d2 --- /dev/null +++ b/src/components/TokenizedInput/components/Field/FieldPopup.tsx @@ -0,0 +1,27 @@ +/* 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/__tests__/useField.test.tsx b/src/components/TokenizedInput/components/Field/__tests__/useField.test.tsx new file mode 100644 index 00000000..b70392c1 --- /dev/null +++ b/src/components/TokenizedInput/components/Field/__tests__/useField.test.tsx @@ -0,0 +1,179 @@ +import * as React from 'react'; + +import {act, renderHook} from '@testing-library/react'; + +import {TokenizedInputComponentContextProvider} from '../../../context/TokenizedInputComponentsContext'; +import {FocusContext, InputContext, OptionsContext} from '../../../context/TokenizedInputContext'; +import type { + TokenValueBase, + TokenizedInputFocusInfo, + TokenizedInputInfo, + TokenizedInputOptionsInfo, +} from '../../../types'; +import {useField} from '../useField'; + +describe('useField', () => { + const mockOnFocus = jest.fn(); + const mockOnChange = jest.fn(); + + const mockFocusInfo = { + state: { + focus: {idx: 0, key: 'key'}, + }, + }; + const mockInputInfo = { + callbacks: {onChangeToken: jest.fn()}, + state: { + tokens: [{id: '1', kind: 'regular', value: {key: 'User', value: 'Ivan'}}], + fields: [{key: 'key'}, {key: 'value'}], + isEditable: true, + isClearable: true, + className: 'custom-class', + wrapperRef: {current: null}, + }, + }; + + const mockOptionsInfo = { + onSuggest: jest.fn(), + shouldAllowBlur: jest.fn().mockReturnValue(true), + suggestionsInitialCall: { + setValue: jest.fn(), + }, + }; + + const mockComponentsInfo = { + Suggestions: () =>
, + Field: () =>
, + Wrapper: () =>
, + TokenList: () =>
, + Token: () =>
, + }; + + const wrapper = ({children}: {children: React.ReactNode}) => ( + } + > + } + > + } + > + + {children} + + + + + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should initialize correctly', () => { + const {result} = renderHook( + () => + useField({ + idx: 0, + fieldKey: 'key', + value: 'value', + onFocus: mockOnFocus, + onChange: mockOnChange, + }), + {wrapper}, + ); + + expect(result.current.state.isFocused).toBe(true); + expect(result.current.state.visibleValue).toBe('value'); + }); + + it('should handle onChange', () => { + const {result} = renderHook( + () => + useField({ + idx: 0, + fieldKey: 'key', + value: 'value', + onFocus: mockOnFocus, + onChange: mockOnChange, + }), + {wrapper}, + ); + + const event = { + target: {value: 'new value'}, + } as React.ChangeEvent; + + act(() => { + result.current.inputProps.onChange?.(event); + }); + + expect(mockOnChange).toHaveBeenCalledWith(0, 'key', 'new value'); + expect(result.current.state.hideSuggestions).toBe(false); + }); + + it('should handle onFocus', () => { + const {result} = renderHook( + () => + useField({ + idx: 0, + fieldKey: 'key', + value: 'value', + onFocus: mockOnFocus, + onChange: mockOnChange, + }), + {wrapper}, + ); + + act(() => { + result.current.inputProps.onFocus?.({} as React.FocusEvent); + }); + + expect(mockOnFocus).toHaveBeenCalledWith(0, 'key'); + }); + + it('should handle onMouseDown and onMouseUp', () => { + const {result} = renderHook( + () => + useField({ + idx: 0, + fieldKey: 'key', + value: 'value', + onFocus: mockOnFocus, + onChange: mockOnChange, + }), + {wrapper}, + ); + + act(() => { + result.current.inputProps.onMouseDown?.({} as React.MouseEvent); + }); + + expect(mockOptionsInfo.suggestionsInitialCall.setValue).toHaveBeenCalledWith(true); + + act(() => { + result.current.inputProps.onMouseUp?.({} as React.MouseEvent); + }); + }); + + it('should handle onClick', () => { + const {result} = renderHook( + () => + useField({ + idx: 0, + fieldKey: 'key', + value: 'value', + onFocus: mockOnFocus, + onChange: mockOnChange, + }), + {wrapper}, + ); + + act(() => { + result.current.inputProps.onClick?.({} as React.MouseEvent); + }); + + expect(result.current.state.hideSuggestions).toBe(false); + }); +}); 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..ebe018a9 --- /dev/null +++ b/src/components/TokenizedInput/components/Field/useField.ts @@ -0,0 +1,218 @@ +import * as React from 'react'; + +import {KeyCode, b} from '../../constants'; +import {useFocusContext, useOptionsContext, 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 = useFocusContext(); + const options = useOptionsContext(); + 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); + // When autoFocus is true, we want to focus the input but keep suggestions hidden initially + // until the user interacts with the input (types, clicks, etc.) to avoid popping up + // suggestions immediately on mount without explicit user intent. + 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); + } + // We also intentionally omit `onFocus` from dependencies because `onFocus` might change + // on every token update, which would cause this effect to run with a stale closure and + // incorrectly reset selection/offset state. + // 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/__tests__/useSuggestions.test.tsx b/src/components/TokenizedInput/components/Suggestions/hooks/__tests__/useSuggestions.test.tsx new file mode 100644 index 00000000..38372028 --- /dev/null +++ b/src/components/TokenizedInput/components/Suggestions/hooks/__tests__/useSuggestions.test.tsx @@ -0,0 +1,136 @@ +import * as React from 'react'; + +import {act, renderHook} from '@testing-library/react'; + +import { + FocusContext, + InputContext, + OptionsContext, +} from '../../../../context/TokenizedInputContext'; +import type { + TokenValueBase, + TokenizedInputFocusInfo, + TokenizedInputInfo, + TokenizedInputOptionsInfo, +} from '../../../../types'; +import {useSuggestions} from '../useSuggestions'; + +describe('useSuggestions', () => { + const mockOnSuggest = jest.fn(); + const mockOnChangeToken = jest.fn(); + const mockOnFocus = jest.fn(); + + const mockInputInfo = { + state: { + tokens: [{id: '1', kind: 'regular', value: {key: 'User', value: 'Ivan'}}], + fields: [{key: 'key'}, {key: 'value'}], + }, + callbacks: { + onChangeToken: mockOnChangeToken, + }, + }; + + const mockFocusInfo = { + state: { + focus: {idx: 0, key: 'key'}, + }, + callbacks: { + onFocus: mockOnFocus, + }, + }; + + const mockOptionsInfo = { + onSuggest: mockOnSuggest, + debounceDelay: 0, // Set to 0 for easier testing + suggestionsInitialCall: { + value: {current: true}, + setValue: jest.fn(), + }, + }; + + const wrapper = ({children}: {children: React.ReactNode}) => ( + } + > + } + > + } + > + {children} + + + + ); + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should fetch suggestions and update state', async () => { + const mockSuggestions = { + items: [{label: 'Status', search: 'Status', value: {key: 'Status'}}], + }; + mockOnSuggest.mockResolvedValue(mockSuggestions); + + const {result} = renderHook( + () => + useSuggestions({idx: 0, fieldKey: 'key', value: '', offset: 0, inputElement: null}), + {wrapper}, + ); + + expect(result.current.state.isLoading).toBe(true); + + await act(async () => { + jest.runAllTimers(); + }); + + expect(mockOnSuggest).toHaveBeenCalled(); + expect(result.current.state.isLoading).toBe(false); + expect(result.current.state.suggestions.items).toEqual(mockSuggestions.items); + }); + + it('should not fetch suggestions if onSuggest is not provided', async () => { + const optionsWithoutSuggest = { + ...mockOptionsInfo, + onSuggest: undefined, + }; + + const customWrapper = ({children}: {children: React.ReactNode}) => ( + } + > + } + > + + } + > + {children} + + + + ); + + const {result} = renderHook( + () => + useSuggestions({idx: 0, fieldKey: 'key', value: '', offset: 0, inputElement: null}), + {wrapper: customWrapper}, + ); + + await act(async () => { + jest.runAllTimers(); + }); + + expect(result.current.state.isLoading).toBe(false); + expect(result.current.state.suggestions.items).toEqual([]); + }); +}); diff --git a/src/components/TokenizedInput/components/Suggestions/hooks/__tests__/useSuggestionsNavigation.test.tsx b/src/components/TokenizedInput/components/Suggestions/hooks/__tests__/useSuggestionsNavigation.test.tsx new file mode 100644 index 00000000..38099245 --- /dev/null +++ b/src/components/TokenizedInput/components/Suggestions/hooks/__tests__/useSuggestionsNavigation.test.tsx @@ -0,0 +1,121 @@ +import * as React from 'react'; + +import {act, renderHook} from '@testing-library/react'; + +import {FocusContext} from '../../../../context/TokenizedInputContext'; +import type { + TokenValueBase, + TokenizedInputFocusInfo, + TokenizedSuggestionsItem, +} from '../../../../types'; +import {useSuggestionsNavigation} from '../useSuggestionsNavigation'; + +describe('useSuggestionsNavigation', () => { + const mockOnFocus = jest.fn(); + + const mockFocusInfo = { + state: { + focus: {idx: 0, key: 'key'}, + }, + callbacks: { + onFocus: mockOnFocus, + }, + }; + + const wrapper = ({children}: {children: React.ReactNode}) => ( + } + > + {children} + + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call onSelectNext on ArrowDown', () => { + const mockOnSelectNext = jest.fn(); + const mockOnSelectPrev = jest.fn(); + const mockOnApply = jest.fn(); + + const inputElement = document.createElement('input'); + + renderHook( + () => + useSuggestionsNavigation({ + inputElement, + onSelectNext: mockOnSelectNext, + onSelectPrev: mockOnSelectPrev, + onApply: mockOnApply, + suggestion: undefined as unknown as TokenizedSuggestionsItem, + }), + {wrapper}, + ); + + const event = new KeyboardEvent('keydown', {key: 'ArrowDown'}); + + act(() => { + inputElement.dispatchEvent(event); + }); + + expect(mockOnSelectNext).toHaveBeenCalled(); + }); + + it('should call onSelectPrev on ArrowUp', () => { + const mockOnSelectNext = jest.fn(); + const mockOnSelectPrev = jest.fn(); + const mockOnApply = jest.fn(); + + const inputElement = document.createElement('input'); + + renderHook( + () => + useSuggestionsNavigation({ + inputElement, + onSelectNext: mockOnSelectNext, + onSelectPrev: mockOnSelectPrev, + onApply: mockOnApply, + suggestion: undefined as unknown as TokenizedSuggestionsItem, + }), + {wrapper}, + ); + + const event = new KeyboardEvent('keydown', {key: 'ArrowUp'}); + + act(() => { + inputElement.dispatchEvent(event); + }); + + expect(mockOnSelectPrev).toHaveBeenCalled(); + }); + + it('should call onApply on Enter', () => { + const mockOnSelectNext = jest.fn(); + const mockOnSelectPrev = jest.fn(); + const mockOnApply = jest.fn(); + const suggestion = {label: '1', search: '1', value: {}}; + + const inputElement = document.createElement('input'); + + renderHook( + () => + useSuggestionsNavigation({ + inputElement, + onSelectNext: mockOnSelectNext, + onSelectPrev: mockOnSelectPrev, + onApply: mockOnApply, + suggestion, + }), + {wrapper}, + ); + + const event = new KeyboardEvent('keydown', {key: 'Enter'}); + + act(() => { + inputElement.dispatchEvent(event); + }); + + expect(mockOnApply).toHaveBeenCalledWith(suggestion); + }); +}); 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..11b5c3dd --- /dev/null +++ b/src/components/TokenizedInput/components/Suggestions/hooks/useSuggestions.ts @@ -0,0 +1,253 @@ +import * as React from 'react'; + +import {getUniqId} from '@gravity-ui/uikit'; +import debounce from 'lodash/debounce'; + +import {useFocusContext, useInputContext, useOptionsContext} from '../../../context'; +import type { + Token, + TokenValueBase, + TokenizedInputData, + TokenizedSuggestions, + TokenizedSuggestionsItem, +} from '../../../types'; +import {SuggestionsData} from '../types'; +import {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 = useInputContext(); + const focusInfo = useFocusContext(); + const options = useOptionsContext(); + + const {tokens} = inputInfo.state; + const {onChangeToken, onApplyChanges} = inputInfo.callbacks; + const {focus} = focusInfo.state; + const {onFocus} = focusInfo.callbacks; + const { + onSuggest, + debounceDelay, + suggestionsInitialCall, + fullWidthSuggestions, + filterSuggestions, + } = 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>(new Set()); + + const delay = React.useMemo( + () => (typeof debounceDelay === 'number' ? debounceDelay : debounceDelay[fieldKey]), + [debounceDelay, fieldKey], + ); + const mountedRef = React.useRef(true); + React.useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + 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, + }), + ); + + if (!mountedRef.current) { + return; + } + + 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.has(args.fnId)) { + cancelledFns.current.delete(args.fnId); + isCancelled = true; + + return; + } + + if (!searchStr || response.options?.isFilterable === false) { + setSuggestions({ + ...response, + items: sortSuggestions(items), + currentWord, + }); + } else { + const filteredItems = filterSuggestions(items, searchStr); + + setSuggestions({ + ...response, + items: filteredItems, + currentWord, + }); + } + } finally { + if (!isCancelled && mountedRef.current) { + setIsLoadingData(false); + initialLoadingRef.current = false; + } + } + }, + delay, + ), + [delay, filterSuggestions], + ); + + React.useEffect(() => { + handleGetSuggestions.cancel(); + if (currentFnId.current) { + cancelledFns.current.add(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(); + }; + // We only want to fetch suggestions when the input value, cursor offset, or focused field changes. + // Including tokens, selection, or onSuggest might trigger unnecessary fetches and re-renders. + // onSuggest is typically a stable reference or intentionally not meant to trigger re-fetches on identity change. + // 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.kind === 'new'; + + 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 = Boolean(onSuggest) && (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..8f831e9d --- /dev/null +++ b/src/components/TokenizedInput/components/Suggestions/hooks/useSuggestionsNavigation.ts @@ -0,0 +1,95 @@ +import * as React from 'react'; + +import {KeyCode} from '../../../constants'; +import {useFocusContext} 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 = useFocusContext(); + const {focus} = focusInfo.state; + + React.useEffect(() => { + // We use a native DOM event listener here instead of React's synthetic onKeyDown + // to ensure we intercept navigation keys (ArrowUp, ArrowDown, Enter) *before* + // the main Wrapper's synthetic onKeyDown handler processes them. + // This allows suggestions navigation to take precedence over token navigation + // when the suggestions popup is open. + 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..c8f62fa3 --- /dev/null +++ b/src/components/TokenizedInput/components/Suggestions/hooks/useSuggestionsPopupOptions.ts @@ -0,0 +1,46 @@ +import * as React from 'react'; + +import type {PopupOffset} from '@gravity-ui/uikit'; + +import {useInputContext, useOptionsContext} from '../../../context'; + +export const useSuggestionsPopupOptions = (inputElement: HTMLInputElement | null) => { + const inputInfo = useInputContext(); + const options = useOptionsContext(); + + 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..b72307af --- /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 {simpleFilterSuggestions} 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.ts b/src/components/TokenizedInput/components/Suggestions/utils.ts new file mode 100644 index 00000000..c85b5312 --- /dev/null +++ b/src/components/TokenizedInput/components/Suggestions/utils.ts @@ -0,0 +1,41 @@ +import type {TokenValueBase, TokenizedSuggestionsItem} from '../../types'; + +export const sortSuggestions = (items: TokenizedSuggestionsItem[]) => { + 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, + ] as TokenizedSuggestionsItem[]; +}; + +export const simpleFilterSuggestions = ( + items: TokenizedSuggestionsItem[], + search: string, +) => { + if (!search) { + return items; + } + + const searchLower = search.toLowerCase(); + + const filteredItems = items + .map((item) => ({ + ...item, + search: item.search.toLowerCase(), + })) + .filter((item) => item.search.includes(searchLower)) + .sort((a, b) => { + const startA = a.search.indexOf(searchLower); + const startB = b.search.indexOf(searchLower); + + if (startA === startB) { + return a.search.localeCompare(b.search); + } + + return startA - startB; + }); + + return sortSuggestions(filteredItems); +}; 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..601373a3 --- /dev/null +++ b/src/components/TokenizedInput/components/Tokens/Token/RegularToken.tsx @@ -0,0 +1,49 @@ +import {Xmark} from '@gravity-ui/icons'; + +import i18n from '../../../i18n'; + +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..256d93e7 --- /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 = ( + | ({kind: 'new'} & NewTokenProps) + | ({kind?: 'regular'} & RegularTokenProps) +) & { + NewToken?: typeof NewTokenComponent; + RegularToken?: typeof RegularTokenComponent; +}; + +const TokenComponent = ({ + NewToken = NewTokenComponent, + RegularToken = RegularTokenComponent, + ...props +}: TokenProps) => { + if (props.kind === 'new') { + 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/__tests__/useNewToken.test.tsx b/src/components/TokenizedInput/components/Tokens/Token/hooks/__tests__/useNewToken.test.tsx new file mode 100644 index 00000000..0c1aeec8 --- /dev/null +++ b/src/components/TokenizedInput/components/Tokens/Token/hooks/__tests__/useNewToken.test.tsx @@ -0,0 +1,159 @@ +import * as React from 'react'; + +import {renderHook} from '@testing-library/react'; + +import { + FocusContext, + InputContext, + OptionsContext, +} from '../../../../../context/TokenizedInputContext'; +import type { + TokenValueBase, + TokenizedInputFocusInfo, + TokenizedInputInfo, + TokenizedInputOptionsInfo, +} from '../../../../../types'; +import {useNewToken} from '../useNewToken'; + +describe('useNewToken', () => { + const mockOnChangeToken = jest.fn(); + const mockOnFocus = jest.fn(); + + const mockFields = [ + {key: 'key', type: 'text'}, + {key: 'value', type: 'text'}, + ]; + + const mockInputInfo = { + state: { + tokens: [], + fields: mockFields, + placeholder: 'Placeholder', + }, + callbacks: { + onChangeToken: mockOnChangeToken, + }, + }; + + const mockFocusInfo = { + state: { + autoFocus: true, + }, + callbacks: { + onFocus: mockOnFocus, + }, + }; + + const mockOptionsInfo = { + shouldAllowBlur: jest.fn().mockReturnValue(true), + }; + + const wrapper = ({children}: {children: React.ReactNode}) => ( + } + > + } + > + } + > + {children} + + + + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return default token if not present in tokens array', () => { + const {result} = renderHook(() => useNewToken(0), {wrapper}); + + expect(result.current.state.token).toEqual({ + id: 'new-token', + value: {key: '', value: ''}, + kind: 'new', + }); + }); + + it('should return existing token if present in tokens array', () => { + const inputInfoWithTokens = { + ...mockInputInfo, + state: { + ...mockInputInfo.state, + tokens: [{id: '1', kind: 'new', value: {key: 'User', value: ''}}], + }, + }; + + const wrapperWithTokens = ({children}: {children: React.ReactNode}) => ( + } + > + } + > + {children} + + + ); + + const {result} = renderHook(() => useNewToken(0), {wrapper: wrapperWithTokens}); + + expect(result.current.state.token).toEqual({ + id: '1', + kind: 'new', + value: {key: 'User', value: ''}, + }); + }); + + it('should check if field is hidden', () => { + const {result} = renderHook(() => useNewToken(0), {wrapper}); + + expect(result.current.callbacks.checkIsHidden(0)).toBe(false); // First field is never hidden + expect(result.current.callbacks.checkIsHidden(1)).toBe(true); // Second field is hidden because first is empty + }); + + it('should check if field is autoFocused', () => { + const {result} = renderHook(() => useNewToken(0), {wrapper}); + + expect(result.current.callbacks.checkIsAutoFocus(0)).toBe(true); // First field is autoFocused if autoFocus is true + expect(result.current.callbacks.checkIsAutoFocus(1)).toBe(false); // Second field is never autoFocused + }); + + it('should return correct placeholder', () => { + const {result} = renderHook(() => useNewToken(0), {wrapper}); + + expect(result.current.callbacks.getPlaceholder(0)).toBe('Placeholder'); + expect(result.current.callbacks.getPlaceholder(1)).toBeUndefined(); + }); + + it('should return correct placeholder from function', () => { + const placeholderFn = jest.fn().mockReturnValue('Dynamic Placeholder'); + const inputInfoWithFnPlaceholder = { + ...mockInputInfo, + state: { + ...mockInputInfo.state, + placeholder: placeholderFn, + }, + }; + + const wrapperWithFnPlaceholder = ({children}: {children: React.ReactNode}) => ( + } + > + } + > + {children} + + + ); + + const {result} = renderHook(() => useNewToken(0), {wrapper: wrapperWithFnPlaceholder}); + + expect(result.current.callbacks.getPlaceholder(0)).toBe('Dynamic Placeholder'); + expect(placeholderFn).toHaveBeenCalledWith('new', {key: '', value: ''}, 0); + }); +}); diff --git a/src/components/TokenizedInput/components/Tokens/Token/hooks/__tests__/useRegularToken.test.tsx b/src/components/TokenizedInput/components/Tokens/Token/hooks/__tests__/useRegularToken.test.tsx new file mode 100644 index 00000000..44837546 --- /dev/null +++ b/src/components/TokenizedInput/components/Tokens/Token/hooks/__tests__/useRegularToken.test.tsx @@ -0,0 +1,201 @@ +import * as React from 'react'; + +import {act, renderHook} from '@testing-library/react'; + +import {TokenizedInputComponentContextProvider} from '../../../../../context/TokenizedInputComponentsContext'; +import { + FocusContext, + InputContext, + OptionsContext, +} from '../../../../../context/TokenizedInputContext'; +import type { + TokenValueBase, + TokenizedInputFocusInfo, + TokenizedInputInfo, + TokenizedInputOptionsInfo, +} from '../../../../../types'; +import {useRegularToken} from '../useRegularToken'; + +describe('useRegularToken', () => { + const mockOnChangeToken = jest.fn(); + const mockOnFocus = jest.fn(); + const mockOnRemoveToken = jest.fn(); + const mockOnApplyChanges = jest.fn(); + + const mockFields = [ + {key: 'key', type: 'text'}, + {key: 'value', type: 'text'}, + ]; + + const mockToken = { + id: '1', + kind: 'regular', + value: {key: 'User', value: 'Ivan'}, + }; + + const mockInputInfo = { + state: { + tokens: [mockToken], + fields: mockFields, + placeholder: 'Placeholder', + isEditable: true, + }, + callbacks: { + onChangeToken: mockOnChangeToken, + onRemoveToken: mockOnRemoveToken, + onApplyChanges: mockOnApplyChanges, + }, + }; + + const mockFocusInfo = { + state: { + autoFocus: false, + }, + callbacks: { + onFocus: mockOnFocus, + }, + }; + + const mockOptionsInfo = { + shouldAllowBlur: jest.fn().mockReturnValue(true), + }; + + const mockComponentsInfo = { + Field: () =>
, + Wrapper: () =>
, + TokenList: () =>
, + Token: () =>
, + Suggestions: () =>
, + }; + + const wrapper = ({children}: {children: React.ReactNode}) => ( + } + > + } + > + } + > + + {children} + + + + + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return token state and callbacks', () => { + const {result} = renderHook(() => useRegularToken(0), {wrapper}); + + expect(result.current.state.token).toEqual(mockToken); + expect(result.current.state.showRemoveButton).toBe(true); + expect(result.current.state.isEditable).toBe(true); + }); + + it('should handle onChangeField', () => { + const {result} = renderHook(() => useRegularToken(0), {wrapper}); + + act(() => { + result.current.callbacks.onChangeField(0, 'key', 'NewUser'); + }); + + expect(mockOnChangeToken).toHaveBeenCalledWith(0, {key: 'NewUser'}); + }); + + it('should handle onRemove', () => { + const {result} = renderHook(() => useRegularToken(0), {wrapper}); + + act(() => { + result.current.callbacks.onRemove(); + }); + + expect(mockOnRemoveToken).toHaveBeenCalledWith(0); + expect(mockOnFocus).toHaveBeenCalledWith({idx: 0, key: 'key'}); + }); + + it('should handle onBlur and apply changes if there are changes', () => { + const {result} = renderHook(() => useRegularToken(0), {wrapper}); + + act(() => { + result.current.callbacks.onChangeField(0, 'key', 'NewUser'); + }); + + const event = { + currentTarget: { + contains: jest.fn().mockReturnValue(false), + }, + relatedTarget: {}, + } as unknown as React.FocusEvent; + + act(() => { + result.current.callbacks.onBlur(event); + }); + + expect(mockOnApplyChanges).toHaveBeenCalledWith(true); + }); + + it('should not apply changes on blur if there are no changes', () => { + const {result} = renderHook(() => useRegularToken(0), {wrapper}); + + const event = { + currentTarget: { + contains: jest.fn().mockReturnValue(false), + }, + relatedTarget: {}, + } as unknown as React.FocusEvent; + + act(() => { + result.current.callbacks.onBlur(event); + }); + + expect(mockOnApplyChanges).not.toHaveBeenCalled(); + }); + + it('should return correct placeholder from function', () => { + const placeholderFn = jest.fn().mockReturnValue('Dynamic Placeholder'); + const inputInfoWithFnPlaceholder = { + ...mockInputInfo, + state: { + ...mockInputInfo.state, + placeholder: placeholderFn, + }, + }; + + const wrapperWithFnPlaceholder = ({children}: {children: React.ReactNode}) => ( + } + > + } + > + + } + > + + {children} + + + + + ); + + const {result} = renderHook(() => useRegularToken(0), {wrapper: wrapperWithFnPlaceholder}); + + expect(result.current.callbacks.getPlaceholder(0)).toBe('Dynamic Placeholder'); + expect(placeholderFn).toHaveBeenCalledWith('regular', {key: 'User', value: 'Ivan'}, 0); + }); + + it('should return undefined placeholder if placeholder is not a function', () => { + const {result} = renderHook(() => useRegularToken(0), {wrapper}); + + expect(result.current.callbacks.getPlaceholder(0)).toBeUndefined(); + }); +}); diff --git a/src/components/TokenizedInput/components/Tokens/Token/hooks/__tests__/useTokenCallbacks.test.tsx b/src/components/TokenizedInput/components/Tokens/Token/hooks/__tests__/useTokenCallbacks.test.tsx new file mode 100644 index 00000000..0f582b83 --- /dev/null +++ b/src/components/TokenizedInput/components/Tokens/Token/hooks/__tests__/useTokenCallbacks.test.tsx @@ -0,0 +1,56 @@ +import * as React from 'react'; + +import {renderHook} from '@testing-library/react'; + +import { + FocusContext, + InputContext, + OptionsContext, +} from '../../../../../context/TokenizedInputContext'; +import type { + TokenValueBase, + TokenizedInputFocusInfo, + TokenizedInputInfo, + TokenizedInputOptionsInfo, +} from '../../../../../types'; +import {useTokenCallbacks} from '../useTokenCallbacks'; + +describe('useTokenCallbacks', () => { + it('should provide onChangeField and onFocusField callbacks', () => { + const mockInputInfo = { + callbacks: {onChangeToken: jest.fn()}, + }; + const mockFocusInfo = { + callbacks: {onFocus: jest.fn()}, + }; + const mockOptionsInfo = { + shouldAllowBlur: jest.fn().mockReturnValue(true), + }; + + const wrapper = ({children}: {children: React.ReactNode}) => ( + } + > + } + > + + } + > + {children} + + + + ); + + const {result} = renderHook(() => useTokenCallbacks(), {wrapper}); + + result.current.onChangeField(1, 'key', 'value'); + expect(mockInputInfo.callbacks.onChangeToken).toHaveBeenCalledWith(1, {key: 'value'}); + + result.current.onFocusField(1, 'key'); + expect(mockFocusInfo.callbacks.onFocus).toHaveBeenCalledWith({idx: 1, key: 'key'}); + }); +}); 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..ec973f16 --- /dev/null +++ b/src/components/TokenizedInput/components/Tokens/Token/hooks/useNewToken.ts @@ -0,0 +1,77 @@ +import * as React from 'react'; + +import {b} from '../../../../constants'; +import {useFocusContext, useInputContext, useTokenizedInputComponents} from '../../../../context'; +import {getDefaultTokenValue} from '../../../../utils'; + +import {useTokenCallbacks} from './useTokenCallbacks'; + +export const useNewToken = (idx: number) => { + const inputInfo = useInputContext(); + const focusInfo = useFocusContext(); + 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), kind: 'new'}, + [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..5aab10b6 --- /dev/null +++ b/src/components/TokenizedInput/components/Tokens/Token/hooks/useRegularToken.ts @@ -0,0 +1,102 @@ +import * as React from 'react'; + +import {b} from '../../../../constants'; +import {useInputContext, useTokenizedInputComponents} from '../../../../context'; +import {useApplyCallbackOnBlur} from '../../../../hooks'; +import type {RegularToken, TokenValueBase} from '../../../../types'; + +import {useTokenCallbacks} from './useTokenCallbacks'; + +export const useRegularToken = (idx: number) => { + const inputInfo = useInputContext(); + const {Field} = useTokenizedInputComponents(); + + const {tokens, isEditable, fields, placeholder} = inputInfo.state; + const {onApplyChanges, onRemoveToken} = inputInfo.callbacks; + const {onChangeField, onFocusField} = useTokenCallbacks(); + + const token = tokens[idx] as RegularToken; + + const hasChanges = React.useRef(false); + + React.useEffect(() => { + hasChanges.current = false; + }, [idx]); + + 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..84bf1ffb --- /dev/null +++ b/src/components/TokenizedInput/components/Tokens/Token/hooks/useTokenCallbacks.ts @@ -0,0 +1,35 @@ +import * as React from 'react'; + +import {useFocusContext, useInputContext} from '../../../../context'; + +export const useTokenCallbacks = () => { + const inputInfo = useInputContext(); + const focusInfo = useFocusContext(); + + 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..8fe37790 --- /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.kind === 'new') { + return null; + } + + return ; + })} + +
+ ); +} diff --git a/src/components/TokenizedInput/components/Tokens/TokenList/__tests__/useTokenList.test.tsx b/src/components/TokenizedInput/components/Tokens/TokenList/__tests__/useTokenList.test.tsx new file mode 100644 index 00000000..f8657a76 --- /dev/null +++ b/src/components/TokenizedInput/components/Tokens/TokenList/__tests__/useTokenList.test.tsx @@ -0,0 +1,104 @@ +import * as React from 'react'; + +import {renderHook} from '@testing-library/react'; + +import {TokenizedInputComponentContextProvider} from '../../../../context/TokenizedInputComponentsContext'; +import { + FocusContext, + InputContext, + OptionsContext, +} from '../../../../context/TokenizedInputContext'; +import type { + TokenValueBase, + TokenizedInputFocusInfo, + TokenizedInputInfo, + TokenizedInputOptionsInfo, +} from '../../../../types'; +import {useTokenList} from '../useTokenList'; + +describe('useTokenList', () => { + const mockToken = { + id: '1', + kind: 'regular', + value: {key: 'User', value: 'Ivan'}, + }; + + const mockNewToken = { + id: '2', + kind: 'new', + value: {key: 'Status', value: ''}, + }; + + const mockInputInfo = { + state: { + tokens: [mockToken, mockNewToken], + }, + }; + + const mockFocusInfo = { + state: { + focus: {idx: 0, key: 'key'}, + }, + }; + + const mockOptionsInfo = { + shouldAllowBlur: jest.fn().mockReturnValue(true), + }; + + const mockComponentsInfo = { + Token: () =>
, + Wrapper: () =>
, + TokenList: () =>
, + Field: () =>
, + Suggestions: () =>
, + }; + + const wrapper = ({children}: {children: React.ReactNode}) => ( + } + > + } + > + } + > + + {children} + + + + + ); + + it('should return tokens, newTokenIdx, Token component, and classNames', () => { + const {result} = renderHook(() => useTokenList(), {wrapper}); + + expect(result.current.tokens).toEqual([mockToken, mockNewToken]); + expect(result.current.newTokenIdx).toBe(1); // One regular token before it + expect(result.current.Token).toBeDefined(); + expect(result.current.classNames.wrapper).toBe('gc-tokenized-input__token-list'); + }); + + it('should calculate newTokenIdx correctly when there are no regular tokens', () => { + const inputInfoOnlyNew = { + state: { + tokens: [mockNewToken], + }, + }; + + const wrapperOnlyNew = ({children}: {children: React.ReactNode}) => ( + } + > + + {children} + + + ); + + const {result} = renderHook(() => useTokenList(), {wrapper: wrapperOnlyNew}); + + expect(result.current.newTokenIdx).toBe(0); + }); +}); 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..13d860a1 --- /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 {useInputContext, useTokenizedInputComponents} from '../../../context'; + +export const useTokenList = () => { + const inputInfo = useInputContext(); + const {Token} = useTokenizedInputComponents(); + + const {tokens} = inputInfo.state; + + const newTokenIdx = tokens.filter((t) => t.kind !== 'new').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..ac6b41ac --- /dev/null +++ b/src/components/TokenizedInput/components/Wrapper/Wrapper.tsx @@ -0,0 +1,33 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +import * as React from 'react'; + +import {Xmark} from '@gravity-ui/icons'; +import {Icon} from '@gravity-ui/uikit'; + +import i18n from '../../i18n'; + +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/__tests__/useBlurHandler.test.tsx b/src/components/TokenizedInput/components/Wrapper/hooks/__tests__/useBlurHandler.test.tsx new file mode 100644 index 00000000..a8870a8e --- /dev/null +++ b/src/components/TokenizedInput/components/Wrapper/hooks/__tests__/useBlurHandler.test.tsx @@ -0,0 +1,191 @@ +import * as React from 'react'; + +import {renderHook} from '@testing-library/react'; + +import {KeyCode} from '../../../../constants'; +import { + FocusContext, + InputContext, + OptionsContext, +} from '../../../../context/TokenizedInputContext'; +import type { + TokenValueBase, + TokenizedInputFocusInfo, + TokenizedInputInfo, + TokenizedInputOptionsInfo, +} from '../../../../types'; +import {useBlurHandler} from '../useBlurHandler'; + +describe('useBlurHandler', () => { + const mockOnFocus = jest.fn(); + const mockOnBlur = jest.fn(); + const mockOnApplyChanges = jest.fn(); + const mockGetFocusRules = jest.fn(); + const mockGetCursorOffset = jest.fn(); + const mockCheckKey = jest.fn(); + + const mockFocusInfo = { + state: { + focus: {idx: 0, key: 'value'}, + }, + callbacks: { + onFocus: mockOnFocus, + onBlur: mockOnBlur, + getFocusRules: mockGetFocusRules, + }, + }; + + const mockInputInfo = { + callbacks: { + onApplyChanges: mockOnApplyChanges, + }, + }; + + const mockOptionsInfo = { + shouldAllowBlur: jest.fn().mockReturnValue(true), + }; + + const mockShortcuts = { + isTokenModifier: jest.fn(), + isFieldModifier: jest.fn(), + isApplyModifier: jest.fn(), + isUndo: jest.fn(), + isRedo: jest.fn(), + }; + + const wrapper = ({children}: {children: React.ReactNode}) => ( + } + > + } + > + } + > + {children} + + + + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should handle Escape key', () => { + mockCheckKey.mockImplementation((_, key) => key === KeyCode.Escape); + + const {result} = renderHook( + () => + useBlurHandler({ + shortcuts: mockShortcuts, + checkKey: mockCheckKey, + getCursorOffset: mockGetCursorOffset, + }), + {wrapper}, + ); + + const event = { + preventDefault: jest.fn(), + target: {blur: jest.fn()}, + } as unknown as React.KeyboardEvent; + + const handled = result.current(event); + + expect(handled).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + expect((event.target as HTMLInputElement).blur).toHaveBeenCalled(); + expect(mockOnBlur).toHaveBeenCalled(); + }); + + it('should handle Enter key with apply modifier', () => { + mockCheckKey.mockImplementation((_, key) => key === KeyCode.Enter); + mockShortcuts.isApplyModifier.mockReturnValue(true); + + const {result} = renderHook( + () => + useBlurHandler({ + shortcuts: mockShortcuts, + checkKey: mockCheckKey, + getCursorOffset: mockGetCursorOffset, + }), + {wrapper}, + ); + + const event = { + preventDefault: jest.fn(), + target: {blur: jest.fn()}, + } as unknown as React.KeyboardEvent; + + const handled = result.current(event); + + expect(handled).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockOnApplyChanges).toHaveBeenCalled(); + expect((event.target as HTMLInputElement).blur).toHaveBeenCalled(); + expect(mockOnBlur).toHaveBeenCalled(); + }); + + it('should handle Enter key without apply modifier', () => { + mockCheckKey.mockImplementation((_, key) => key === KeyCode.Enter); + mockShortcuts.isApplyModifier.mockReturnValue(false); + mockGetCursorOffset.mockReturnValue(5); + mockGetFocusRules.mockReturnValue({ + nextToken: {idx: 1, key: 'value'}, + }); + + const {result} = renderHook( + () => + useBlurHandler({ + shortcuts: mockShortcuts, + checkKey: mockCheckKey, + getCursorOffset: mockGetCursorOffset, + }), + {wrapper}, + ); + + const event = { + preventDefault: jest.fn(), + target: {blur: jest.fn()}, + } as unknown as React.KeyboardEvent; + + const handled = result.current(event); + + expect(handled).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockOnApplyChanges).toHaveBeenCalled(); + expect(mockGetCursorOffset).toHaveBeenCalledWith(event.target); + expect(mockGetFocusRules).toHaveBeenCalledWith({ + idx: 0, + key: 'value', + offset: 5, + }); + expect(mockOnFocus).toHaveBeenCalledWith({idx: 1, key: 'value'}); + expect((event.target as HTMLInputElement).blur).not.toHaveBeenCalled(); + }); + + it('should return false for other keys', () => { + mockCheckKey.mockReturnValue(false); + + const {result} = renderHook( + () => + useBlurHandler({ + shortcuts: mockShortcuts, + checkKey: mockCheckKey, + getCursorOffset: mockGetCursorOffset, + }), + {wrapper}, + ); + + const event = { + preventDefault: jest.fn(), + target: {blur: jest.fn()}, + } as unknown as React.KeyboardEvent; + + const handled = result.current(event); + + expect(handled).toBe(false); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/TokenizedInput/components/Wrapper/hooks/__tests__/useDeleteHandler.test.tsx b/src/components/TokenizedInput/components/Wrapper/hooks/__tests__/useDeleteHandler.test.tsx new file mode 100644 index 00000000..555ec488 --- /dev/null +++ b/src/components/TokenizedInput/components/Wrapper/hooks/__tests__/useDeleteHandler.test.tsx @@ -0,0 +1,234 @@ +import * as React from 'react'; + +import {renderHook} from '@testing-library/react'; + +import {KeyCode} from '../../../../constants'; +import { + FocusContext, + InputContext, + OptionsContext, +} from '../../../../context/TokenizedInputContext'; +import type { + TokenValueBase, + TokenizedInputFocusInfo, + TokenizedInputInfo, + TokenizedInputOptionsInfo, +} from '../../../../types'; +import {useDeleteHandler} from '../useDeleteHandler'; + +describe('useDeleteHandler', () => { + const mockOnFocus = jest.fn(); + const mockOnRemoveToken = jest.fn(); + const mockOnChangeToken = jest.fn(); + const mockGetFocusRules = jest.fn(); + const mockCheckKey = jest.fn(); + + const mockFocusInfo = { + state: { + focus: {idx: 1, key: 'value'}, + }, + callbacks: { + onFocus: mockOnFocus, + getFocusRules: mockGetFocusRules, + }, + }; + + const mockInputInfo = { + state: { + fields: [{key: 'key'}, {key: 'value'}], + tokens: [ + {id: '1', kind: 'regular', value: {key: 'User', value: 'Ivan'}}, + {id: '2', kind: 'regular', value: {key: 'Status', value: 'Active'}}, + ], + }, + callbacks: { + onRemoveToken: mockOnRemoveToken, + onChangeToken: mockOnChangeToken, + }, + }; + + const mockShortcuts = { + isTokenModifier: jest.fn(), + isFieldModifier: jest.fn(), + isApplyModifier: jest.fn(), + isUndo: jest.fn(), + isRedo: jest.fn(), + }; + + const mockOptionsInfo = { + shouldAllowBlur: jest.fn().mockReturnValue(true), + }; + + const wrapper = ({children}: {children: React.ReactNode}) => ( + } + > + } + > + {children} + + + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should delete token when token modifier is pressed', () => { + mockCheckKey.mockImplementation((_, key) => key === KeyCode.Backspace); + mockShortcuts.isTokenModifier.mockReturnValue(true); + + const {result} = renderHook( + () => + useDeleteHandler({ + shortcuts: mockShortcuts, + checkKey: mockCheckKey, + }), + {wrapper}, + ); + + const event = { + preventDefault: jest.fn(), + target: {selectionStart: 0, selectionEnd: 0}, + } as unknown as React.KeyboardEvent; + + const handled = result.current(event); + + expect(handled).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockOnRemoveToken).toHaveBeenCalledWith(1); + expect(mockOnFocus).toHaveBeenCalledWith({ + idx: 1, + key: 'key', + }); + }); + + it('should delete character when cursor is at the start of the input', () => { + mockCheckKey.mockImplementation((_, key) => key === KeyCode.Backspace); + mockShortcuts.isTokenModifier.mockReturnValue(false); + mockGetFocusRules.mockReturnValue({ + prevField: {idx: 1, key: 'value'}, + }); + + const deleteFocusInfo = { + ...mockFocusInfo, + state: { + focus: {idx: 2, key: 'key'}, + }, + }; + + const deleteInputInfo = { + ...mockInputInfo, + state: { + ...mockInputInfo.state, + tokens: [ + ...mockInputInfo.state.tokens, + {id: '3', kind: 'regular', value: {key: 'key', value: 'value'}}, + ], + }, + callbacks: { + ...mockInputInfo.callbacks, + }, + }; + + const deleteWrapper = ({children}: {children: React.ReactNode}) => ( + } + > + } + > + + } + > + {children} + + + + ); + + const {result} = renderHook( + () => + useDeleteHandler({ + shortcuts: mockShortcuts, + checkKey: mockCheckKey, + }), + {wrapper: deleteWrapper}, + ); + + const event = { + preventDefault: jest.fn(), + target: {selectionStart: 0, selectionEnd: 0}, + } as unknown as React.KeyboardEvent; + + const handled = result.current(event); + + expect(handled).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockOnChangeToken).not.toHaveBeenCalled(); + expect(mockOnFocus).toHaveBeenCalledWith({ + idx: 1, + key: 'value', + offset: -1, + }); + }); + + it('should not delete character if readOnlyField', () => { + mockCheckKey.mockImplementation((_, key) => key === KeyCode.Backspace); + mockShortcuts.isTokenModifier.mockReturnValue(false); + mockGetFocusRules.mockReturnValue({ + prevField: {idx: 1, key: 'value'}, + }); + + const readOnlyInputInfo = { + ...mockInputInfo, + state: { + ...mockInputInfo.state, + tokens: [ + {id: '1', kind: 'regular', value: {key: 'User', value: 'Ivan'}}, + { + id: '2', + kind: 'regular', + value: {key: 'Status', value: 'Active'}, + options: {readOnlyFields: ['value']}, + }, + ], + }, + }; + + const readOnlyWrapper = ({children}: {children: React.ReactNode}) => ( + } + > + } + > + {children} + + + ); + + const {result} = renderHook( + () => + useDeleteHandler({ + shortcuts: mockShortcuts, + checkKey: mockCheckKey, + }), + {wrapper: readOnlyWrapper}, + ); + + const event = { + preventDefault: jest.fn(), + target: {selectionStart: 0, selectionEnd: 0}, + } as unknown as React.KeyboardEvent; + + const handled = result.current(event); + + expect(handled).toBe(false); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(mockOnChangeToken).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/TokenizedInput/components/Wrapper/hooks/__tests__/useKeyDownHandler.test.tsx b/src/components/TokenizedInput/components/Wrapper/hooks/__tests__/useKeyDownHandler.test.tsx new file mode 100644 index 00000000..235459e6 --- /dev/null +++ b/src/components/TokenizedInput/components/Wrapper/hooks/__tests__/useKeyDownHandler.test.tsx @@ -0,0 +1,197 @@ +import * as React from 'react'; + +import {renderHook} from '@testing-library/react'; + +import { + FocusContext, + InputContext, + OptionsContext, +} from '../../../../context/TokenizedInputContext'; +import type { + TokenValueBase, + TokenizedInputFocusInfo, + TokenizedInputInfo, + TokenizedInputOptionsInfo, +} from '../../../../types'; +import {useKeyDownHandler} from '../useKeyDownHandler'; + +jest.mock('../useShortcuts', () => ({ + useShortcuts: () => ({ + isTokenModifier: jest.fn(), + isFieldModifier: jest.fn(), + isApplyModifier: jest.fn(), + isUndo: jest.fn(), + isRedo: jest.fn(), + }), +})); + +jest.mock('../useNavigationHandler', () => ({ + useNavigationHandler: () => jest.fn().mockReturnValue(false), +})); + +jest.mock('../useDeleteHandler', () => ({ + useDeleteHandler: () => jest.fn().mockReturnValue(false), +})); + +jest.mock('../useBlurHandler', () => ({ + useBlurHandler: () => jest.fn().mockReturnValue(false), +})); + +jest.mock('../useUndoRedoHandler', () => ({ + useUndoRedoHandler: () => jest.fn().mockReturnValue(false), +})); + +describe('useKeyDownHandler', () => { + const mockOnApplyChanges = jest.fn(); + const mockOnChangeToken = jest.fn(); + const mockOnFocus = jest.fn(); + const mockOnKeyDown = jest.fn(); + + const mockAction = jest.fn(); + + const mockInputInfo = { + state: { + tokens: [{id: '1', kind: 'regular', value: {key: 'User', value: 'Ivan'}}], + fields: [ + { + key: 'key', + specialKeysActions: [ + {key: 'Enter', action: mockAction}, + {key: (e: React.KeyboardEvent) => e.key === 'Space', action: mockAction}, + ], + }, + {key: 'value'}, + ], + }, + callbacks: { + onApplyChanges: mockOnApplyChanges, + onChangeToken: mockOnChangeToken, + }, + }; + + const mockFocusInfo = { + state: { + focus: {idx: 0, key: 'key'}, + }, + callbacks: { + onFocus: mockOnFocus, + }, + }; + + const mockOptionsInfo = { + onKeyDown: mockOnKeyDown, + }; + + const wrapper = ({children}: {children: React.ReactNode}) => ( + } + > + } + > + } + > + {children} + + + + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should handle external onKeyDown', () => { + mockOnKeyDown.mockReturnValue(true); + + const {result} = renderHook(() => useKeyDownHandler(), {wrapper}); + + const event = {key: 'A', target: {selectionStart: 0}} as unknown as React.KeyboardEvent; + result.current(event); + + expect(mockOnKeyDown).toHaveBeenCalledWith({ + token: mockInputInfo.state.tokens[0], + offset: 0, + focus: mockFocusInfo.state.focus, + onFocus: mockOnFocus, + onChange: mockOnChangeToken, + onApply: mockOnApplyChanges, + event, + }); + }); + + it('should handle specialKeysActions with string key', () => { + mockOnKeyDown.mockReturnValue(false); + + const {result} = renderHook(() => useKeyDownHandler(), {wrapper}); + + const event = {key: 'Enter', target: {selectionStart: 0}} as unknown as React.KeyboardEvent; + result.current(event); + + expect(mockAction).toHaveBeenCalledWith({ + token: mockInputInfo.state.tokens[0], + offset: 0, + focus: mockFocusInfo.state.focus, + onFocus: mockOnFocus, + onChange: mockOnChangeToken, + onApply: mockOnApplyChanges, + event, + }); + }); + + it('should handle specialKeysActions with function key', () => { + mockOnKeyDown.mockReturnValue(false); + + const {result} = renderHook(() => useKeyDownHandler(), {wrapper}); + + const event = {key: 'Space', target: {selectionStart: 0}} as unknown as React.KeyboardEvent; + result.current(event); + + expect(mockAction).toHaveBeenCalledWith({ + token: mockInputInfo.state.tokens[0], + offset: 0, + focus: mockFocusInfo.state.focus, + onFocus: mockOnFocus, + onChange: mockOnChangeToken, + onApply: mockOnApplyChanges, + event, + }); + }); + + it('should not handle specialKeysActions if focus is not on the field', () => { + mockOnKeyDown.mockReturnValue(false); + + const focusInfoValue = { + ...mockFocusInfo, + state: { + focus: {idx: 0, key: 'value'}, + }, + }; + + const valueWrapper = ({children}: {children: React.ReactNode}) => ( + } + > + } + > + + } + > + {children} + + + + ); + + const {result} = renderHook(() => useKeyDownHandler(), {wrapper: valueWrapper}); + + const event = {key: 'Enter', target: {selectionStart: 0}} as unknown as React.KeyboardEvent; + result.current(event); + + expect(mockAction).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/TokenizedInput/components/Wrapper/hooks/__tests__/useNavigationHandler.test.tsx b/src/components/TokenizedInput/components/Wrapper/hooks/__tests__/useNavigationHandler.test.tsx new file mode 100644 index 00000000..e9f0dd0e --- /dev/null +++ b/src/components/TokenizedInput/components/Wrapper/hooks/__tests__/useNavigationHandler.test.tsx @@ -0,0 +1,287 @@ +import * as React from 'react'; + +import {renderHook} from '@testing-library/react'; + +import {KeyCode} from '../../../../constants'; +import { + FocusContext, + InputContext, + OptionsContext, +} from '../../../../context/TokenizedInputContext'; +import type { + TokenValueBase, + TokenizedInputFocusInfo, + TokenizedInputInfo, + TokenizedInputOptionsInfo, +} from '../../../../types'; +import {useNavigationHandler} from '../useNavigationHandler'; + +describe('useNavigationHandler', () => { + const mockOnFocus = jest.fn(); + const mockGetFocusRules = jest.fn(); + const mockGetCursorOffset = jest.fn(); + const mockCheckKey = jest.fn(); + + const mockFocusInfo = { + state: { + focus: {idx: 1, key: 'value'}, + }, + callbacks: { + onFocus: mockOnFocus, + getFocusRules: mockGetFocusRules, + }, + }; + + const mockInputInfo = { + state: { + fields: [{key: 'key'}, {key: 'value'}], + tokens: [ + {id: '1', kind: 'regular', value: {key: 'User', value: 'Ivan'}}, + {id: '2', kind: 'regular', value: {key: 'Status', value: 'Active'}}, + ], + }, + }; + + const mockOptionsInfo = { + shouldAllowBlur: jest.fn().mockReturnValue(true), + }; + + const mockShortcuts = { + isTokenModifier: jest.fn(), + isFieldModifier: jest.fn(), + isApplyModifier: jest.fn(), + isUndo: jest.fn(), + isRedo: jest.fn(), + }; + + const wrapper = ({children}: {children: React.ReactNode}) => ( + } + > + } + > + } + > + {children} + + + + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should handle Tab key to move to next field', () => { + mockCheckKey.mockImplementation((_, key) => key === KeyCode.Tab); + mockGetFocusRules.mockReturnValue({ + nextField: {idx: 1, key: 'value'}, + }); + + const {result} = renderHook( + () => + useNavigationHandler({ + shortcuts: mockShortcuts, + checkKey: mockCheckKey, + getCursorOffset: mockGetCursorOffset, + }), + {wrapper}, + ); + + const event = { + preventDefault: jest.fn(), + shiftKey: false, + } as unknown as React.KeyboardEvent; + + const handled = result.current(event); + + expect(handled).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockOnFocus).toHaveBeenCalledWith({ + idx: 1, + key: 'value', + }); + }); + + it('should handle Shift+Tab key to move to prev field', () => { + mockCheckKey.mockImplementation((_, key) => key === KeyCode.Tab); + mockGetFocusRules.mockReturnValue({ + prevField: {idx: 0, key: 'value'}, + }); + + const {result} = renderHook( + () => + useNavigationHandler({ + shortcuts: mockShortcuts, + checkKey: mockCheckKey, + getCursorOffset: mockGetCursorOffset, + }), + {wrapper}, + ); + + const event = { + preventDefault: jest.fn(), + shiftKey: true, + } as unknown as React.KeyboardEvent; + + const handled = result.current(event); + + expect(handled).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockOnFocus).toHaveBeenCalledWith({ + idx: 0, + key: 'value', + }); + }); + + it('should handle ArrowRight key to move to next field with field modifier', () => { + mockCheckKey.mockImplementation((_, key) => key === KeyCode.ArrowRight); + mockShortcuts.isFieldModifier.mockReturnValue(true); + mockGetFocusRules.mockReturnValue({ + nextField: {idx: 1, key: 'value'}, + }); + + const {result} = renderHook( + () => + useNavigationHandler({ + shortcuts: mockShortcuts, + checkKey: mockCheckKey, + getCursorOffset: mockGetCursorOffset, + }), + {wrapper}, + ); + + const event = { + preventDefault: jest.fn(), + } as unknown as React.KeyboardEvent; + + const handled = result.current(event); + + expect(handled).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockOnFocus).toHaveBeenCalledWith({ + idx: 1, + key: 'value', + }); + }); + + it('should handle ArrowLeft key to move to prev field with field modifier', () => { + mockCheckKey.mockImplementation((_, key) => key === KeyCode.ArrowLeft); + mockShortcuts.isFieldModifier.mockReturnValue(true); + mockGetFocusRules.mockReturnValue({ + prevField: {idx: 0, key: 'value'}, + }); + + const {result} = renderHook( + () => + useNavigationHandler({ + shortcuts: mockShortcuts, + checkKey: mockCheckKey, + getCursorOffset: mockGetCursorOffset, + }), + {wrapper}, + ); + + const event = { + preventDefault: jest.fn(), + } as unknown as React.KeyboardEvent; + + const handled = result.current(event); + + expect(handled).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockOnFocus).toHaveBeenCalledWith({ + idx: 0, + key: 'value', + }); + }); + + it('should handle ArrowRight key to move to next token with token modifier', () => { + mockCheckKey.mockImplementation((_, key) => key === KeyCode.ArrowRight); + mockShortcuts.isFieldModifier.mockReturnValue(false); + mockShortcuts.isTokenModifier.mockReturnValue(true); + mockGetFocusRules.mockReturnValue({ + nextToken: {idx: 2, key: 'key'}, + }); + + const {result} = renderHook( + () => + useNavigationHandler({ + shortcuts: mockShortcuts, + checkKey: mockCheckKey, + getCursorOffset: mockGetCursorOffset, + }), + {wrapper}, + ); + + const event = { + preventDefault: jest.fn(), + } as unknown as React.KeyboardEvent; + + const handled = result.current(event); + + expect(handled).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockOnFocus).toHaveBeenCalledWith({ + idx: 2, + key: 'key', + }); + }); + + it('should handle ArrowLeft key to move to prev token with token modifier', () => { + mockCheckKey.mockImplementation((_, key) => key === KeyCode.ArrowLeft); + mockShortcuts.isFieldModifier.mockReturnValue(false); + mockShortcuts.isTokenModifier.mockReturnValue(true); + mockGetFocusRules.mockReturnValue({ + prevToken: {idx: 0, key: 'key'}, + }); + + const {result} = renderHook( + () => + useNavigationHandler({ + shortcuts: mockShortcuts, + checkKey: mockCheckKey, + getCursorOffset: mockGetCursorOffset, + }), + {wrapper}, + ); + + const event = { + preventDefault: jest.fn(), + } as unknown as React.KeyboardEvent; + + const handled = result.current(event); + + expect(handled).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockOnFocus).toHaveBeenCalledWith({ + idx: 0, + key: 'key', + }); + }); + + it('should return false for other keys', () => { + mockCheckKey.mockReturnValue(false); + + const {result} = renderHook( + () => + useNavigationHandler({ + shortcuts: mockShortcuts, + checkKey: mockCheckKey, + getCursorOffset: mockGetCursorOffset, + }), + {wrapper}, + ); + + const event = { + preventDefault: jest.fn(), + } as unknown as React.KeyboardEvent; + + const handled = result.current(event); + + expect(handled).toBe(false); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/TokenizedInput/components/Wrapper/hooks/__tests__/useShortcuts.test.ts b/src/components/TokenizedInput/components/Wrapper/hooks/__tests__/useShortcuts.test.ts new file mode 100644 index 00000000..3c393c2d --- /dev/null +++ b/src/components/TokenizedInput/components/Wrapper/hooks/__tests__/useShortcuts.test.ts @@ -0,0 +1,58 @@ +import {renderHook} from '@testing-library/react'; + +import {useShortcuts} from '../useShortcuts'; + +describe('useShortcuts', () => { + let originalUserAgent: string; + + beforeEach(() => { + originalUserAgent = navigator.userAgent; + }); + + afterEach(() => { + Object.defineProperty(navigator, 'userAgent', { + value: originalUserAgent, + configurable: true, + }); + }); + + it('should return mac shortcuts when userAgent contains MAC', () => { + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', + configurable: true, + }); + + const {result} = renderHook(() => useShortcuts()); + + const event = { + metaKey: true, + altKey: false, + ctrlKey: false, + shiftKey: false, + code: 'KeyZ', + } as KeyboardEvent; + + expect(result.current.isTokenModifier(event as unknown as React.KeyboardEvent)).toBe(true); + expect(result.current.isUndo(event as unknown as React.KeyboardEvent)).toBe(true); + }); + + it('should return win shortcuts when userAgent does not contain MAC', () => { + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + configurable: true, + }); + + const {result} = renderHook(() => useShortcuts()); + + const event = { + metaKey: false, + altKey: false, + ctrlKey: true, + shiftKey: false, + code: 'KeyZ', + } as KeyboardEvent; + + expect(result.current.isApplyModifier(event as unknown as React.KeyboardEvent)).toBe(true); + expect(result.current.isUndo(event as unknown as React.KeyboardEvent)).toBe(true); + }); +}); diff --git a/src/components/TokenizedInput/components/Wrapper/hooks/__tests__/useUndoRedoHandler.test.tsx b/src/components/TokenizedInput/components/Wrapper/hooks/__tests__/useUndoRedoHandler.test.tsx new file mode 100644 index 00000000..5f79bad3 --- /dev/null +++ b/src/components/TokenizedInput/components/Wrapper/hooks/__tests__/useUndoRedoHandler.test.tsx @@ -0,0 +1,132 @@ +import * as React from 'react'; + +import {renderHook} from '@testing-library/react'; + +import { + FocusContext, + InputContext, + OptionsContext, +} from '../../../../context/TokenizedInputContext'; +import type { + TokenValueBase, + TokenizedInputFocusInfo, + TokenizedInputInfo, + TokenizedInputOptionsInfo, +} from '../../../../types'; +import {useUndoRedoHandler} from '../useUndoRedoHandler'; + +describe('useUndoRedoHandler', () => { + const mockOnFocus = jest.fn(); + const mockOnUndo = jest.fn(); + const mockOnRedo = jest.fn(); + + const mockFocusInfo = { + state: { + focus: {idx: 0, key: 'value'}, + }, + callbacks: { + onFocus: mockOnFocus, + }, + }; + + const mockInputInfo = { + state: { + fields: [{key: 'value'}], + }, + callbacks: { + onUndo: mockOnUndo, + onRedo: mockOnRedo, + }, + }; + + const mockShortcuts = { + isTokenModifier: jest.fn(), + isFieldModifier: jest.fn(), + isApplyModifier: jest.fn(), + isUndo: jest.fn(), + isRedo: jest.fn(), + }; + + const mockOptionsInfo = { + shouldAllowBlur: jest.fn().mockReturnValue(true), + }; + + const wrapper = ({children}: {children: React.ReactNode}) => ( + } + > + } + > + } + > + {children} + + + + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should handle undo', () => { + mockShortcuts.isUndo.mockReturnValue(true); + mockShortcuts.isRedo.mockReturnValue(false); + mockOnUndo.mockReturnValue([{id: '1', kind: 'regular', value: {}}]); + + const {result} = renderHook(() => useUndoRedoHandler({shortcuts: mockShortcuts}), { + wrapper, + }); + + const event = {preventDefault: jest.fn()} as unknown as React.KeyboardEvent; + const handled = result.current(event); + + expect(handled).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockOnUndo).toHaveBeenCalled(); + expect(mockOnFocus).toHaveBeenCalledWith({ + idx: 1, + key: 'value', + ignoreChecks: true, + }); + }); + + it('should handle redo', () => { + mockShortcuts.isUndo.mockReturnValue(false); + mockShortcuts.isRedo.mockReturnValue(true); + mockOnRedo.mockReturnValue([{id: '1', kind: 'new', value: {}}]); + + const {result} = renderHook(() => useUndoRedoHandler({shortcuts: mockShortcuts}), { + wrapper, + }); + + const event = {preventDefault: jest.fn()} as unknown as React.KeyboardEvent; + const handled = result.current(event); + + expect(handled).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockOnRedo).toHaveBeenCalled(); + expect(mockOnFocus).toHaveBeenCalledWith({ + idx: 0, + key: 'value', + ignoreChecks: true, + }); + }); + + it('should return false if not undo or redo', () => { + mockShortcuts.isUndo.mockReturnValue(false); + mockShortcuts.isRedo.mockReturnValue(false); + + const {result} = renderHook(() => useUndoRedoHandler({shortcuts: mockShortcuts}), { + wrapper, + }); + + const event = {preventDefault: jest.fn()} as unknown as React.KeyboardEvent; + const handled = result.current(event); + + expect(handled).toBe(false); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/TokenizedInput/components/Wrapper/hooks/__tests__/useWrapper.test.tsx b/src/components/TokenizedInput/components/Wrapper/hooks/__tests__/useWrapper.test.tsx new file mode 100644 index 00000000..7068dcd1 --- /dev/null +++ b/src/components/TokenizedInput/components/Wrapper/hooks/__tests__/useWrapper.test.tsx @@ -0,0 +1,165 @@ +import * as React from 'react'; + +import {act, renderHook} from '@testing-library/react'; + +import { + FocusContext, + InputContext, + OptionsContext, +} from '../../../../context/TokenizedInputContext'; +import type { + TokenValueBase, + TokenizedInputFocusInfo, + TokenizedInputInfo, + TokenizedInputOptionsInfo, +} from '../../../../types'; +import {useWrapper} from '../useWrapper'; + +jest.mock('../useKeyDownHandler', () => ({ + useKeyDownHandler: () => jest.fn(), +})); + +describe('useWrapper', () => { + const mockOnApplyChanges = jest.fn(); + const mockOnClearInput = jest.fn(); + const mockOnBlur = jest.fn(); + const mockOnFocus = jest.fn(); + + const mockInputInfo = { + state: { + tokens: [{id: '1', kind: 'regular', value: {key: 'User', value: 'Ivan'}}], + fields: [{key: 'key'}, {key: 'value'}], + isEditable: true, + isClearable: true, + className: 'custom-class', + wrapperRef: {current: null}, + }, + callbacks: { + onApplyChanges: mockOnApplyChanges, + onClearInput: mockOnClearInput, + }, + }; + + const mockFocusInfo = { + state: { + focus: undefined, + }, + callbacks: { + onBlur: mockOnBlur, + onFocus: mockOnFocus, + }, + }; + + const mockOptionsInfo = { + shouldAllowBlur: jest.fn().mockReturnValue(true), + }; + + const wrapper = ({children}: {children: React.ReactNode}) => ( + } + > + } + > + } + > + {children} + + + + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return state and callbacks', () => { + const {result} = renderHook(() => useWrapper(), {wrapper}); + + expect(result.current.state.isEditable).toBe(true); + expect(result.current.state.isClearable).toBe(true); + expect(result.current.state.classNames.wrapper).toContain('custom-class'); + expect(result.current.state.wrapperRef).toEqual({current: null}); + + expect(typeof result.current.callbacks.onBlur).toBe('function'); + expect(typeof result.current.callbacks.onKeyDown).toBe('function'); + expect(typeof result.current.callbacks.onClear).toBe('function'); + }); + + it('should handle onClear', () => { + const {result} = renderHook(() => useWrapper(), {wrapper}); + + act(() => { + result.current.callbacks.onClear(); + }); + + expect(mockOnClearInput).toHaveBeenCalled(); + expect(mockOnFocus).toHaveBeenCalledWith({ + idx: 1, // length of tokens array + key: 'key', // first field key + }); + }); + + it('should handle onBlur', () => { + const {result} = renderHook(() => useWrapper(), {wrapper}); + + const event = { + currentTarget: { + contains: jest.fn().mockReturnValue(false), + }, + relatedTarget: {}, + } as unknown as React.FocusEvent; + + act(() => { + result.current.callbacks.onBlur(event); + }); + + expect(mockOnBlur).toHaveBeenCalled(); + expect(mockOnApplyChanges).toHaveBeenCalled(); + }); + + it('should generate correct class names based on focus and disabled state', () => { + const disabledInputInfo = { + ...mockInputInfo, + state: { + ...mockInputInfo.state, + isEditable: false, + }, + }; + + const focusedFocusInfo = { + ...mockFocusInfo, + state: { + focus: {idx: 0, key: 'key'}, + }, + }; + + const disabledWrapper = ({children}: {children: React.ReactNode}) => ( + } + > + } + > + + } + > + {children} + + + + ); + + const {result} = renderHook(() => useWrapper(), {wrapper: disabledWrapper}); + + expect(result.current.state.classNames.wrapper).toContain( + 'gc-tokenized-input__wrapper_disabled', + ); + expect(result.current.state.classNames.wrapper).toContain( + 'gc-tokenized-input__wrapper_focused', + ); + }); +}); 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/useBlurHandler.ts b/src/components/TokenizedInput/components/Wrapper/hooks/useBlurHandler.ts new file mode 100644 index 00000000..7420129c --- /dev/null +++ b/src/components/TokenizedInput/components/Wrapper/hooks/useBlurHandler.ts @@ -0,0 +1,79 @@ +import * as React from 'react'; + +import {KeyCode} from '../../../constants'; +import {useFocusContext, useInputContext} from '../../../context'; +import type {TokenValueBase} from '../../../types'; + +import type {ShortcutMap} from './useShortcuts'; + +type UseBlurHandlerOptions = { + shortcuts: ShortcutMap; + checkKey: (e: React.KeyboardEvent, key: string) => boolean; + getCursorOffset: (input: HTMLInputElement) => number | undefined; +}; + +export const useBlurHandler = ({ + shortcuts, + checkKey, + getCursorOffset, +}: UseBlurHandlerOptions) => { + const focusInfo = useFocusContext(); + const inputInfo = useInputContext(); + + const {focus} = focusInfo.state; + const {getFocusRules, onFocus, onBlur} = focusInfo.callbacks; + const {onApplyChanges} = inputInfo.callbacks; + + return React.useCallback( + (e: React.KeyboardEvent) => { + const handler = () => { + const input = e.target as HTMLInputElement; + input.blur(); + onBlur(); + }; + + if (checkKey(e, KeyCode.Enter)) { + e.preventDefault(); + onApplyChanges(); + + if (shortcuts.isApplyModifier(e)) { + handler(); + + return true; + } else { + if (!focus) { + return false; + } + + const input = e.target as HTMLInputElement; + const focusRules = getFocusRules({ + ...focus, + offset: getCursorOffset(input), + }); + + onFocus(focusRules.nextToken); + + return true; + } + } + if (checkKey(e, KeyCode.Escape)) { + e.preventDefault(); + handler(); + + return true; + } + + return false; + }, + [ + checkKey, + focus, + getCursorOffset, + getFocusRules, + onApplyChanges, + onBlur, + onFocus, + shortcuts, + ], + ); +}; diff --git a/src/components/TokenizedInput/components/Wrapper/hooks/useDeleteHandler.ts b/src/components/TokenizedInput/components/Wrapper/hooks/useDeleteHandler.ts new file mode 100644 index 00000000..067e8662 --- /dev/null +++ b/src/components/TokenizedInput/components/Wrapper/hooks/useDeleteHandler.ts @@ -0,0 +1,105 @@ +import * as React from 'react'; + +import {KeyCode} from '../../../constants'; +import {useFocusContext, useInputContext} from '../../../context'; +import type {TokenValueBase} from '../../../types'; + +import type {ShortcutMap} from './useShortcuts'; + +type UseDeleteHandlerOptions = { + shortcuts: ShortcutMap; + checkKey: (e: React.KeyboardEvent, key: string) => boolean; +}; + +export const useDeleteHandler = ({ + shortcuts, + checkKey, +}: UseDeleteHandlerOptions) => { + const focusInfo = useFocusContext(); + const inputInfo = useInputContext(); + + const {focus} = focusInfo.state; + const {getFocusRules, onFocus} = focusInfo.callbacks; + const {fields, tokens} = inputInfo.state; + const {onRemoveToken, onChangeToken} = inputInfo.callbacks; + + return React.useCallback( + (e: React.KeyboardEvent) => { + const input = e.target as HTMLInputElement; + + if (!focus || !input) { + return false; + } + + if (checkKey(e, KeyCode.Backspace)) { + if (shortcuts.isTokenModifier(e)) { + e.preventDefault(); + + let idx = focus.idx; + + if (!tokens[idx]) { + idx--; + } + + if ( + idx < 0 || + (tokens[idx].kind === 'regular' && 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; + const prevToken = tokens[idx]; + + if ( + (focus.key === key && focus.idx === idx) || + !prevToken || + (prevToken.kind === 'regular' && + prevToken.options?.readOnlyFields?.includes(key)) + ) { + return false; + } + + e.preventDefault(); + if (idx === focus.idx) { + onChangeToken(idx, { + [key]: tokens[idx].value[key].slice(0, -1), + } as Partial); + } + + onFocus({...prevField, offset: -1}); + + return true; + } + } + + return false; + }, + [ + checkKey, + fields, + focus, + getFocusRules, + onChangeToken, + onFocus, + onRemoveToken, + shortcuts, + tokens, + ], + ); +}; 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..cccf3bd1 --- /dev/null +++ b/src/components/TokenizedInput/components/Wrapper/hooks/useKeyDownHandler.ts @@ -0,0 +1,186 @@ +import * as React from 'react'; + +import {useFocusContext, useInputContext, useOptionsContext} from '../../../context'; +import type {TokenValueBase} from '../../../types'; + +import {useBlurHandler} from './useBlurHandler'; +import {useDeleteHandler} from './useDeleteHandler'; +import {useNavigationHandler} from './useNavigationHandler'; +import {useShortcuts} from './useShortcuts'; +import {useUndoRedoHandler} from './useUndoRedoHandler'; + +export const useKeyDownHandler = () => { + const shortcuts = useShortcuts(); + const focusInfo = useFocusContext(); + const inputInfo = useInputContext(); + const options = useOptionsContext(); + + const {fields, tokens} = inputInfo.state; + const {onChangeToken, onApplyChanges} = inputInfo.callbacks; + const {focus} = focusInfo.state; + const {onFocus} = 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], + ); + + const navigationHandler = useNavigationHandler({ + shortcuts, + checkKey, + getCursorOffset, + }); + + const deleteHandler = useDeleteHandler({ + shortcuts, + checkKey, + }); + + const blurHandler = useBlurHandler({ + shortcuts, + checkKey, + getCursorOffset, + }); + + const undoRedo = useUndoRedoHandler({ + shortcuts, + }); + + 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}`, + kind: 'new', + value: {}, + }; + + action({ + token, + offset: input.selectionStart ?? 0, + focus, + onFocus, + onChange: onChangeToken, + onApply: onApplyChanges, + event: e, + }); + + return true; + }, + [fields, focus, onApplyChanges, onChangeToken, onFocus, tokens], + ); + + 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}`, + kind: 'new', + 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/useNavigationHandler.ts b/src/components/TokenizedInput/components/Wrapper/hooks/useNavigationHandler.ts new file mode 100644 index 00000000..a3676611 --- /dev/null +++ b/src/components/TokenizedInput/components/Wrapper/hooks/useNavigationHandler.ts @@ -0,0 +1,199 @@ +import * as React from 'react'; + +import {KeyCode} from '../../../constants'; +import {useFocusContext, useInputContext} from '../../../context'; +import type {TokenValueBase} from '../../../types'; + +import type {ShortcutMap} from './useShortcuts'; + +type UseNavigationHandlerOptions = { + shortcuts: ShortcutMap; + checkKey: (e: React.KeyboardEvent, key: string) => boolean; + getCursorOffset: (input: HTMLInputElement) => number | undefined; +}; + +export const useNavigationHandler = ({ + shortcuts, + checkKey, + getCursorOffset, +}: UseNavigationHandlerOptions) => { + const focusInfo = useFocusContext(); + const inputInfo = useInputContext(); + + const {focus} = focusInfo.state; + const {getFocusRules, onFocus} = focusInfo.callbacks; + const {fields, tokens} = inputInfo.state; + + // 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], + ); + + return React.useCallback( + (e: React.KeyboardEvent) => { + if (!e.shiftKey) { + if (shortcuts.isTokenModifier(e)) { + return jumpToNeighborToken(e); + } + if (shortcuts.isFieldModifier(e)) { + return jumpToNeighborField(e); + } + if (!checkKey(e, KeyCode.Tab)) { + return moveToNeighborField(e); + } + } + return tabJumping(e); + }, + [ + checkKey, + jumpToNeighborField, + jumpToNeighborToken, + moveToNeighborField, + shortcuts, + tabJumping, + ], + ); +}; diff --git a/src/components/TokenizedInput/components/Wrapper/hooks/useShortcuts.ts b/src/components/TokenizedInput/components/Wrapper/hooks/useShortcuts.ts new file mode 100644 index 00000000..a363bdb0 --- /dev/null +++ b/src/components/TokenizedInput/components/Wrapper/hooks/useShortcuts.ts @@ -0,0 +1,35 @@ +import * as React from 'react'; + +export 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'), +}; + +export const useShortcuts = () => { + return React.useMemo(() => { + if (typeof window === 'undefined') { + return winShortcuts; + } + return navigator.userAgent.toUpperCase().includes('MAC') ? macShortcuts : winShortcuts; + }, []); +}; diff --git a/src/components/TokenizedInput/components/Wrapper/hooks/useUndoRedoHandler.ts b/src/components/TokenizedInput/components/Wrapper/hooks/useUndoRedoHandler.ts new file mode 100644 index 00000000..bd7a0a29 --- /dev/null +++ b/src/components/TokenizedInput/components/Wrapper/hooks/useUndoRedoHandler.ts @@ -0,0 +1,58 @@ +import * as React from 'react'; + +import {useFocusContext, useInputContext} from '../../../context'; +import type {Token, TokenValueBase} from '../../../types'; + +import type {ShortcutMap} from './useShortcuts'; + +type UseUndoRedoHandlerOptions = { + shortcuts: ShortcutMap; +}; + +export const useUndoRedoHandler = ({ + shortcuts, +}: UseUndoRedoHandlerOptions) => { + const focusInfo = useFocusContext(); + const inputInfo = useInputContext(); + + const {focus} = focusInfo.state; + const {onFocus} = focusInfo.callbacks; + const {fields} = inputInfo.state; + const {onUndo, onRedo} = inputInfo.callbacks; + + return React.useCallback( + (e: React.KeyboardEvent) => { + if (!focus) { + return false; + } + + const focusLastToken = (newTokens: Token[]) => { + const idx = newTokens.findIndex((t) => t.kind === 'new'); + + onFocus({ + idx: idx === -1 ? newTokens.length : idx, + key: fields[0].key, + ignoreChecks: true, + }); + }; + + if (shortcuts.isRedo(e)) { + e.preventDefault(); + const newTokens = onRedo(); + focusLastToken(newTokens); + + return true; + } + if (shortcuts.isUndo(e)) { + e.preventDefault(); + const newTokens = onUndo(); + focusLastToken(newTokens); + + return true; + } + + return false; + }, + [fields, focus, onFocus, onRedo, onUndo, shortcuts], + ); +}; 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..f3fad2b3 --- /dev/null +++ b/src/components/TokenizedInput/components/Wrapper/hooks/useWrapper.ts @@ -0,0 +1,53 @@ +import * as React from 'react'; + +import {b} from '../../../constants'; +import {useFocusContext, useInputContext} from '../../../context'; +import {useApplyCallbackOnBlur} from '../../../hooks'; + +import {useKeyDownHandler} from './useKeyDownHandler'; + +export const useWrapper = () => { + const focusInfo = useFocusContext(); + const inputInfo = useInputContext(); + + 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..0e07ba10 --- /dev/null +++ b/src/components/TokenizedInput/constants.ts @@ -0,0 +1,22 @@ +import {block} from '../utils/cn'; + +export const b = block('tokenized-input'); + +export const 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', +} as const; + +export type KeyCodeType = (typeof KeyCode)[keyof typeof KeyCode]; 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..e55f0d0a --- /dev/null +++ b/src/components/TokenizedInput/context/TokenizedInputContext.tsx @@ -0,0 +1,163 @@ +import * as React from 'react'; + +import {simpleFilterSuggestions} from '../components/Suggestions'; +import {useSuggestionsInitialCall, useTokenizedInputFocus, useTokenizedInputInfo} from '../hooks'; +import {useTokenizedInputComponentFocus} from '../hooks/useTokenizedInputComponentFocus'; +import type { + TokenValueBase, + TokenizedInputData, + TokenizedInputFocusInfo, + TokenizedInputInfo, + TokenizedInputOptionsInfo, +} from '../types'; + +export type TokenizedInputContextOptions = { + inputInfo: TokenizedInputInfo; + focusInfo: TokenizedInputFocusInfo; + options: TokenizedInputOptionsInfo; +}; + +export const InputContext = React.createContext | undefined>( + undefined, +); +export const FocusContext = React.createContext< + TokenizedInputFocusInfo | undefined +>(undefined); +export const OptionsContext = React.createContext< + TokenizedInputOptionsInfo | undefined +>(undefined); + +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, + filterSuggestions = simpleFilterSuggestions, + 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 optionsValue = React.useMemo( + () => + ({ + onSuggest, + onKeyDown, + debounceDelay, + suggestionsInitialCall, + fullWidthSuggestions, + shouldAllowBlur, + filterSuggestions, + }) as unknown as TokenizedInputOptionsInfo, + [ + debounceDelay, + shouldAllowBlur, + fullWidthSuggestions, + onKeyDown, + onSuggest, + suggestionsInitialCall, + filterSuggestions, + ], + ); + + return ( + + } + > + } + > + {children} + + + + ); +} + +export const useInputContext = () => { + const ctx = React.useContext(InputContext as unknown as React.Context>); + + if (!ctx) { + throw new Error('InputContext is not defined'); + } + + return ctx; +}; + +export const useFocusContext = () => { + const ctx = React.useContext( + FocusContext as unknown as React.Context>, + ); + + if (!ctx) { + throw new Error('FocusContext is not defined'); + } + + return ctx; +}; + +export const useOptionsContext = () => { + const ctx = React.useContext( + OptionsContext as unknown as React.Context>, + ); + + if (!ctx) { + throw new Error('OptionsContext is not defined'); + } + + return ctx; +}; + +export const useTokenizedInput = (): TokenizedInputContextOptions => { + const inputInfo = useInputContext(); + const focusInfo = useFocusContext(); + const options = useOptionsContext(); + + return React.useMemo( + () => ({ + inputInfo, + focusInfo, + options, + }), + [inputInfo, focusInfo, options], + ); +}; 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/__tests__/useApplyCallbackOnBlur.test.tsx b/src/components/TokenizedInput/hooks/__tests__/useApplyCallbackOnBlur.test.tsx new file mode 100644 index 00000000..bde4f928 --- /dev/null +++ b/src/components/TokenizedInput/hooks/__tests__/useApplyCallbackOnBlur.test.tsx @@ -0,0 +1,93 @@ +import * as React from 'react'; + +import {renderHook} from '@testing-library/react'; + +import {OptionsContext} from '../../context/TokenizedInputContext'; +import type {TokenValueBase, TokenizedInputOptionsInfo} from '../../types'; +import {useApplyCallbackOnBlur} from '../useApplyCallbackOnBlur'; + +describe('useApplyCallbackOnBlur', () => { + it('should call fn when focus moves outside and shouldAllowBlur returns true', () => { + const fn = jest.fn(); + const shouldAllowBlur = jest.fn().mockReturnValue(true); + + const wrapper = ({children}: {children: React.ReactNode}) => ( + } + > + {children} + + ); + + const {result} = renderHook(() => useApplyCallbackOnBlur(fn), {wrapper}); + + const event = { + currentTarget: { + contains: jest.fn().mockReturnValue(false), + }, + relatedTarget: {}, + } as unknown as React.FocusEvent; + + result.current(event); + + expect(event.currentTarget.contains).toHaveBeenCalledWith(event.relatedTarget); + expect(shouldAllowBlur).toHaveBeenCalledWith(event); + expect(fn).toHaveBeenCalledWith(event); + }); + + it('should not call fn when focus moves inside', () => { + const fn = jest.fn(); + const shouldAllowBlur = jest.fn().mockReturnValue(true); + + const wrapper = ({children}: {children: React.ReactNode}) => ( + } + > + {children} + + ); + + const {result} = renderHook(() => useApplyCallbackOnBlur(fn), {wrapper}); + + const event = { + currentTarget: { + contains: jest.fn().mockReturnValue(true), + }, + relatedTarget: {}, + } as unknown as React.FocusEvent; + + result.current(event); + + expect(event.currentTarget.contains).toHaveBeenCalledWith(event.relatedTarget); + expect(shouldAllowBlur).not.toHaveBeenCalled(); + expect(fn).not.toHaveBeenCalled(); + }); + + it('should not call fn when shouldAllowBlur returns false', () => { + const fn = jest.fn(); + const shouldAllowBlur = jest.fn().mockReturnValue(false); + + const wrapper = ({children}: {children: React.ReactNode}) => ( + } + > + {children} + + ); + + const {result} = renderHook(() => useApplyCallbackOnBlur(fn), {wrapper}); + + const event = { + currentTarget: { + contains: jest.fn().mockReturnValue(false), + }, + relatedTarget: {}, + } as unknown as React.FocusEvent; + + result.current(event); + + expect(event.currentTarget.contains).toHaveBeenCalledWith(event.relatedTarget); + expect(shouldAllowBlur).toHaveBeenCalledWith(event); + expect(fn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/TokenizedInput/hooks/__tests__/useSuggestionsInitialCall.test.ts b/src/components/TokenizedInput/hooks/__tests__/useSuggestionsInitialCall.test.ts new file mode 100644 index 00000000..94fbaec7 --- /dev/null +++ b/src/components/TokenizedInput/hooks/__tests__/useSuggestionsInitialCall.test.ts @@ -0,0 +1,67 @@ +import {renderHook} from '@testing-library/react'; + +import type {TokenValueBase, TokenizedInputFocusInfo} from '../../types'; +import {useSuggestionsInitialCall} from '../useSuggestionsInitialCall'; + +describe('useSuggestionsInitialCall', () => { + it('should initialize with true', () => { + const {result} = renderHook(() => useSuggestionsInitialCall(undefined, 'focus-input')); + + expect(result.current.value.current).toBe(true); + }); + + it('should set initialCallRef to false when focus is provided and strategy is focus-input', () => { + const {result} = renderHook(() => + useSuggestionsInitialCall({idx: 0, key: 'key'}, 'focus-input'), + ); + + expect(result.current.value.current).toBe(false); + }); + + it('should set initialCallRef to true when focus is not provided and strategy is focus-input', () => { + const {result} = renderHook(() => useSuggestionsInitialCall(undefined, 'focus-input')); + + expect(result.current.value.current).toBe(true); + }); + + it('should set initialCallRef to true when strategy is not focus-input', () => { + const {result} = renderHook(() => + useSuggestionsInitialCall({idx: 0, key: 'key'}, 'focus-field'), + ); + + expect(result.current.value.current).toBe(true); + }); + + it('should allow setting value manually', () => { + const {result} = renderHook(() => useSuggestionsInitialCall(undefined, 'focus-input')); + + expect(result.current.value.current).toBe(true); + + result.current.setValue(false); + + expect(result.current.value.current).toBe(false); + }); + + it('should update initialCallRef when focus changes', () => { + const mockFocusInfo = { + state: { + focus: {idx: 0, key: 'key'}, + }, + }; + + const {result, rerender} = renderHook( + ({focus}) => useSuggestionsInitialCall(focus.state.focus, 'focus-input'), + { + initialProps: { + focus: mockFocusInfo as unknown as TokenizedInputFocusInfo, + }, + }, + ); + + expect(result.current.value.current).toBe(false); + + rerender({focus: mockFocusInfo as unknown as TokenizedInputFocusInfo}); + + expect(result.current.value.current).toBe(false); + }); +}); diff --git a/src/components/TokenizedInput/hooks/__tests__/useTokenizedInputComponentFocus.test.ts b/src/components/TokenizedInput/hooks/__tests__/useTokenizedInputComponentFocus.test.ts new file mode 100644 index 00000000..2a382f67 --- /dev/null +++ b/src/components/TokenizedInput/hooks/__tests__/useTokenizedInputComponentFocus.test.ts @@ -0,0 +1,105 @@ +import {renderHook} from '@testing-library/react'; + +import type {TokenValueBase, TokenizedInputFocusInfo} from '../../types'; +import {useTokenizedInputComponentFocus} from '../useTokenizedInputComponentFocus'; + +describe('useTokenizedInputComponentFocus', () => { + it('should call onFocus when focus changes from empty to not empty', () => { + const onFocus = jest.fn(); + const onBlur = jest.fn(); + + const {rerender} = renderHook( + ({focusInfo}) => + useTokenizedInputComponentFocus({ + focusInfo, + onFocus, + onBlur, + }), + { + initialProps: { + focusInfo: { + state: {focus: undefined}, + } as unknown as TokenizedInputFocusInfo, + }, + }, + ); + + expect(onFocus).not.toHaveBeenCalled(); + expect(onBlur).not.toHaveBeenCalled(); + + rerender({ + focusInfo: { + state: {focus: {idx: 0, key: 'key'}}, + } as unknown as TokenizedInputFocusInfo, + }); + + expect(onFocus).toHaveBeenCalledTimes(1); + expect(onBlur).not.toHaveBeenCalled(); + }); + + it('should call onBlur when focus changes from not empty to empty', () => { + const onFocus = jest.fn(); + const onBlur = jest.fn(); + + const {rerender} = renderHook( + ({focusInfo}) => + useTokenizedInputComponentFocus({ + focusInfo, + onFocus, + onBlur, + }), + { + initialProps: { + focusInfo: { + state: {focus: {idx: 0, key: 'key'}}, + } as unknown as TokenizedInputFocusInfo, + }, + }, + ); + + expect(onFocus).toHaveBeenCalledTimes(1); // called on initial render because it goes from null to 0 + expect(onBlur).not.toHaveBeenCalled(); + + rerender({ + focusInfo: { + state: {focus: undefined}, + } as unknown as TokenizedInputFocusInfo, + }); + + expect(onFocus).toHaveBeenCalledTimes(1); + expect(onBlur).toHaveBeenCalledTimes(1); + }); + + it('should not call onFocus or onBlur when focus changes between different tokens', () => { + const onFocus = jest.fn(); + const onBlur = jest.fn(); + + const {rerender} = renderHook( + ({focusInfo}) => + useTokenizedInputComponentFocus({ + focusInfo, + onFocus, + onBlur, + }), + { + initialProps: { + focusInfo: { + state: {focus: {idx: 0, key: 'key'}}, + } as unknown as TokenizedInputFocusInfo, + }, + }, + ); + + expect(onFocus).toHaveBeenCalledTimes(1); + expect(onBlur).not.toHaveBeenCalled(); + + rerender({ + focusInfo: { + state: {focus: {idx: 1, key: 'key'}}, + } as unknown as TokenizedInputFocusInfo, + }); + + expect(onFocus).toHaveBeenCalledTimes(1); // not called again + expect(onBlur).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/TokenizedInput/hooks/__tests__/useTokenizedInputFocus.test.ts b/src/components/TokenizedInput/hooks/__tests__/useTokenizedInputFocus.test.ts new file mode 100644 index 00000000..3b9e4422 --- /dev/null +++ b/src/components/TokenizedInput/hooks/__tests__/useTokenizedInputFocus.test.ts @@ -0,0 +1,188 @@ +import {act, renderHook} from '@testing-library/react'; + +import type {Token, TokenField, TokenValueBase, TokenizedInputInfo} from '../../types'; +import {useTokenizedInputFocus} from '../useTokenizedInputFocus'; + +describe('useTokenizedInputFocus', () => { + const mockFields: TokenField[] = [{key: 'key'}, {key: 'value'}]; + + const mockTokens: Token[] = [ + {id: '1', kind: 'regular', value: {key: 'User', value: 'Ivan'}}, + {id: '2', kind: 'new', value: {key: 'Status', value: ''}}, + ]; + + const mockOnApplyChanges = jest.fn(); + + const mockInputInfo = { + state: { + tokens: mockTokens, + }, + callbacks: { + onApplyChanges: mockOnApplyChanges, + }, + } as unknown as TokenizedInputInfo; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should initialize with no focus if autoFocus is false', () => { + const {result} = renderHook(() => + useTokenizedInputFocus({ + fields: mockFields, + inputInfo: mockInputInfo, + autoFocus: false, + }), + ); + + expect(result.current.state.focus).toBeUndefined(); + }); + + it('should initialize with focus on new token if autoFocus is true', () => { + const {result} = renderHook(() => + useTokenizedInputFocus({ + fields: mockFields, + inputInfo: mockInputInfo, + autoFocus: true, + }), + ); + + expect(result.current.state.focus).toEqual({ + idx: 1, // index of new token + key: 'key', + offset: -1, + }); + }); + + it('should handle onFocus to a regular token', () => { + const {result} = renderHook(() => + useTokenizedInputFocus({ + fields: mockFields, + inputInfo: mockInputInfo, + autoFocus: false, + }), + ); + + act(() => { + result.current.callbacks.onFocus({idx: 0, key: 'value', offset: 0}); + }); + + expect(result.current.state.focus).toEqual({ + idx: 0, + key: 'value', + offset: 0, + }); + }); + + it('should handle onBlur', () => { + const {result} = renderHook(() => + useTokenizedInputFocus({ + fields: mockFields, + inputInfo: mockInputInfo, + autoFocus: true, + }), + ); + + expect(result.current.state.focus).toBeDefined(); + + act(() => { + result.current.callbacks.onBlur(); + }); + + expect(result.current.state.focus).toBeUndefined(); + }); + + it('should return correct focus rules', () => { + const {result} = renderHook(() => + useTokenizedInputFocus({ + fields: mockFields, + inputInfo: mockInputInfo, + autoFocus: false, + }), + ); + + const rules = result.current.callbacks.getFocusRules({idx: 0, key: 'key', offset: -1}); + + expect(rules.prevField).toEqual({idx: 0, key: 'key', offset: 0}); // Cannot go before first field of first token + expect(rules.nextField).toEqual({idx: 0, key: 'value', offset: -1}); + expect(rules.prevToken).toEqual({idx: 0, key: 'key', offset: 0}); // Cannot go before first token + expect(rules.nextToken).toEqual({idx: 0, key: 'value', offset: -1}); + }); + + it('should return correct focus rules when moving between tokens', () => { + const {result} = renderHook(() => + useTokenizedInputFocus({ + fields: mockFields, + inputInfo: mockInputInfo, + autoFocus: false, + }), + ); + + const rules = result.current.callbacks.getFocusRules({idx: 0, key: 'value', offset: -1}); + + expect(rules.prevField).toEqual({idx: 0, key: 'value', offset: 0}); + expect(rules.nextField).toEqual({idx: 1, key: 'key', offset: -1}); + expect(rules.prevToken).toEqual({idx: 0, key: 'key', offset: 0}); + expect(rules.nextToken).toEqual({idx: 1, key: 'key', offset: -1}); // Next token is new token, so key is first field + }); + + it('should return correct focus rules for the first token', () => { + const {result} = renderHook(() => + useTokenizedInputFocus({ + fields: mockFields, + inputInfo: mockInputInfo, + autoFocus: false, + }), + ); + + const rules = result.current.callbacks.getFocusRules({idx: 0, key: 'key', offset: -1}); + + expect(rules.prevField).toEqual({idx: 0, key: 'key', offset: 0}); + expect(rules.nextField).toEqual({idx: 0, key: 'value', offset: -1}); + expect(rules.prevToken).toEqual({idx: 0, key: 'key', offset: 0}); + expect(rules.nextToken).toEqual({idx: 0, key: 'value', offset: -1}); + }); + + it('should return correct focus rules for the last (new) token', () => { + const {result} = renderHook(() => + useTokenizedInputFocus({ + fields: mockFields, + inputInfo: mockInputInfo, + autoFocus: false, + }), + ); + + const rules = result.current.callbacks.getFocusRules({idx: 1, key: 'value', offset: -1}); + + expect(rules.prevField).toEqual({idx: 1, key: 'value', offset: 0}); + expect(rules.nextField).toEqual({idx: 2, key: 'key', offset: -1}); + expect(rules.prevToken).toEqual({idx: 1, key: 'key', offset: 0}); + expect(rules.nextToken).toEqual({idx: 2, key: 'key', offset: -1}); + }); + + it('should handle single field tokens', () => { + const singleFieldMockInputInfo = { + state: { + tokens: mockTokens, + }, + callbacks: { + onApplyChanges: mockOnApplyChanges, + }, + } as unknown as TokenizedInputInfo; + + const {result} = renderHook(() => + useTokenizedInputFocus({ + fields: [{key: 'key'}], + inputInfo: singleFieldMockInputInfo, + autoFocus: false, + }), + ); + + const rules = result.current.callbacks.getFocusRules({idx: 0, key: 'key', offset: -1}); + + expect(rules.prevField).toEqual({idx: 0, key: 'key', offset: 0}); + expect(rules.nextField).toEqual({idx: 1, key: 'key', offset: -1}); + expect(rules.prevToken).toEqual({idx: 0, key: 'key', offset: 0}); + expect(rules.nextToken).toEqual({idx: 1, key: 'key', offset: -1}); + }); +}); diff --git a/src/components/TokenizedInput/hooks/__tests__/useTokenizedInputInfo.test.ts b/src/components/TokenizedInput/hooks/__tests__/useTokenizedInputInfo.test.ts new file mode 100644 index 00000000..ccad244f --- /dev/null +++ b/src/components/TokenizedInput/hooks/__tests__/useTokenizedInputInfo.test.ts @@ -0,0 +1,197 @@ +import {act, renderHook} from '@testing-library/react'; + +import type {TokenField, TokenValueBase} from '../../types'; +import {useTokenizedInputInfo} from '../useTokenizedInputInfo'; + +describe('useTokenizedInputInfo', () => { + const mockFields: TokenField[] = [{key: 'key'}, {key: 'value'}]; + + const mockOnChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should initialize with external tokens', () => { + const externalTokens = [{key: 'User', value: 'Ivan'}]; + + const {result} = renderHook(() => + useTokenizedInputInfo({ + tokens: externalTokens, + fields: mockFields, + onChange: mockOnChange, + }), + ); + + expect(result.current.state.tokens).toHaveLength(1); + expect(result.current.state.tokens[0].value).toEqual({key: 'User', value: 'Ivan'}); + expect(result.current.state.tokens[0].kind).toBe('regular'); + }); + + it('should handle onChangeToken for existing token', () => { + const externalTokens = [{key: 'User', value: 'Ivan'}]; + + const {result} = renderHook(() => + useTokenizedInputInfo({ + tokens: externalTokens, + fields: mockFields, + onChange: mockOnChange, + }), + ); + + act(() => { + result.current.callbacks.onChangeToken(0, {value: 'Petr'}); + }); + + expect(result.current.state.tokens[0].value).toEqual({key: 'User', value: 'Petr'}); + expect(mockOnChange).not.toHaveBeenCalled(); // onChange is called on apply + }); + + it('should handle onChangeToken for new token', () => { + const externalTokens = [{key: 'User', value: 'Ivan'}]; + + const {result} = renderHook(() => + useTokenizedInputInfo({ + tokens: externalTokens, + fields: mockFields, + onChange: mockOnChange, + }), + ); + + act(() => { + result.current.callbacks.onChangeToken(1, {key: 'Status'}); + }); + + expect(result.current.state.tokens).toHaveLength(2); + expect(result.current.state.tokens[1].value).toEqual({key: 'Status', value: ''}); + expect(result.current.state.tokens[1].kind).toBe('new'); + }); + + it('should remove empty token on onChangeToken', () => { + const externalTokens = [{key: 'User', value: 'Ivan'}]; + + const {result} = renderHook(() => + useTokenizedInputInfo({ + tokens: externalTokens, + fields: mockFields, + onChange: mockOnChange, + }), + ); + + act(() => { + result.current.callbacks.onChangeToken(0, {key: ' ', value: ' '}); + }); + + expect(result.current.state.tokens).toHaveLength(0); + }); + + it('should handle onApplyChanges', () => { + const externalTokens = [{key: 'User', value: 'Ivan'}]; + + const {result} = renderHook(() => + useTokenizedInputInfo({ + tokens: externalTokens, + fields: mockFields, + onChange: mockOnChange, + }), + ); + + act(() => { + result.current.callbacks.onChangeToken(1, {key: 'Status', value: 'Active'}); + }); + + act(() => { + result.current.callbacks.onApplyChanges(); + }); + + expect(mockOnChange).toHaveBeenCalledWith([ + {key: 'User', value: 'Ivan'}, + {key: 'Status', value: 'Active'}, + ]); + expect(result.current.state.tokens[1].kind).toBe('regular'); + }); + + it('should handle onRemoveToken', () => { + const externalTokens = [ + {key: 'User', value: 'Ivan'}, + {key: 'Status', value: 'Active'}, + ]; + + const {result} = renderHook(() => + useTokenizedInputInfo({ + tokens: externalTokens, + fields: mockFields, + onChange: mockOnChange, + }), + ); + + act(() => { + result.current.callbacks.onRemoveToken(0); + }); + + expect(result.current.state.tokens).toHaveLength(1); + expect(result.current.state.tokens[0].value).toEqual({key: 'Status', value: 'Active'}); + expect(mockOnChange).toHaveBeenCalledWith([{key: 'Status', value: 'Active'}]); + }); + + it('should handle onClearInput', () => { + const externalTokens = [ + {key: 'User', value: 'Ivan'}, + {key: 'Status', value: 'Active'}, + ]; + + const {result} = renderHook(() => + useTokenizedInputInfo({ + tokens: externalTokens, + fields: mockFields, + onChange: mockOnChange, + }), + ); + + act(() => { + result.current.callbacks.onClearInput(); + }); + + expect(result.current.state.tokens).toHaveLength(0); + expect(mockOnChange).toHaveBeenCalledWith([]); + }); + + it('should handle undo and redo', () => { + const externalTokens = [{key: 'User', value: 'Ivan'}]; + + const {result} = renderHook(() => + useTokenizedInputInfo({ + tokens: externalTokens, + fields: mockFields, + onChange: mockOnChange, + }), + ); + + act(() => { + result.current.callbacks.onChangeToken(1, {key: 'Status', value: 'Active'}); + }); + + act(() => { + result.current.callbacks.onApplyChanges(); + }); + + expect(result.current.state.tokens).toHaveLength(2); + + act(() => { + result.current.callbacks.onUndo(); + }); + + expect(result.current.state.tokens).toHaveLength(1); + expect(mockOnChange).toHaveBeenCalledWith([{key: 'User', value: 'Ivan'}]); + + act(() => { + result.current.callbacks.onRedo(); + }); + + expect(result.current.state.tokens).toHaveLength(2); + expect(mockOnChange).toHaveBeenCalledWith([ + {key: 'User', value: 'Ivan'}, + {key: 'Status', value: 'Active'}, + ]); + }); +}); 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..347aa0ba --- /dev/null +++ b/src/components/TokenizedInput/hooks/useApplyCallbackOnBlur.ts @@ -0,0 +1,15 @@ +import * as React from 'react'; + +import {useOptionsContext} from '../context'; + +export const useApplyCallbackOnBlur = (fn: (e: React.FocusEvent) => void) => { + const {shouldAllowBlur} = useOptionsContext(); + 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..5ba56fcf --- /dev/null +++ b/src/components/TokenizedInput/hooks/useSuggestionsInitialCall.ts @@ -0,0 +1,33 @@ +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; + } + // We only want to reset the initial call flag when the focused field changes, + // not when the entire focus object reference or debounceFlushStrategy changes. + // 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..770246c9 --- /dev/null +++ b/src/components/TokenizedInput/hooks/useTokenizedInputFocus.ts @@ -0,0 +1,199 @@ +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.kind === 'new'); + + 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 tokensRef = React.useRef(tokens); + tokensRef.current = tokens; + + const onFocus = React.useCallback( + (newFocus: TokenFocus) => { + const currentTokens = tokensRef.current; + const {idx, key, offset, ignoreChecks} = newFocus; + + const isNewToken = + (idx === currentTokens.length && currentTokens[idx - 1]?.kind === 'new') || + (idx === currentTokens.length + 1 && currentTokens.at(-1)?.kind !== 'new'); + + // new token is being finalized and not all fields are empty + if (isNewToken) { + const hasNonEmptyFields = + Object.values(currentTokens.find((t) => t.kind === 'new')?.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 - currentTokens.length > 0) { + setFocus({ + idx: currentTokens.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 (currentTokens[cur.idx] && currentTokens[cur.idx].kind !== 'new') { + 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) => !currentTokens[cur.idx]?.value?.[f.key]); + const allValuesNonEmptyCondition = fields.some( + (f) => !currentTokens[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], + ); + + 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]?.kind === 'new') { + 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..2e2c5d32 --- /dev/null +++ b/src/components/TokenizedInput/hooks/useTokenizedInputInfo.ts @@ -0,0 +1,228 @@ +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, + }) as Token, + ), + [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.kind !== 'new')), externalTokens)) { + const newTokens = validateTokens(transformTokens(externalTokens)); + + tokensRef.current = newTokens; + setTokens(newTokens); + undoRedoManager.current.init(newTokens); + } + // We only want to sync the internal state when externalTokens change from props, + // to avoid infinite loops if internal tokens or transformation functions change on every render. + // 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: Token[] = [ + ...prevTokens, + { + id: `tokenId${getUniqId()}`, + kind: 'new', + 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} as Token; + }); + const newTokens = removeEmptyTokens(transformedTokens) + .map((t) => { + // apply changes only to existing tokens (not the in-progress new token) + if (currentTokens && t.kind === 'new') { + return undefined; + } + const formattedValue = formatToken?.(t.value) ?? t.value; + + return { + ...t, + kind: 'regular', + 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) => { + if (idx < 0 || idx >= tokensRef.current.length) { + return tokensRef.current; + } + + 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.kind !== 'new')))); + + return newTokens; + }, [onChange]); + + const onRedo = React.useCallback(() => { + const newTokens = undoRedoManager.current.redo(); + + tokensRef.current = newTokens; + setTokens(newTokens); + onChange(getValuesFromTokens(removeEmptyTokens(newTokens.filter((t) => t.kind !== 'new')))); + + 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..3472171e --- /dev/null +++ b/src/components/TokenizedInput/i18n/en.json @@ -0,0 +1,6 @@ +{ + "suggestions.items_not_found": "No matches for the search string «{{text}}»", + "clear_input": "Clear input", + "remove_token": "Remove token {{index}}", + "field_aria_label": "Field {{key}} in token {{index}}" +} diff --git a/src/components/TokenizedInput/i18n/index.ts b/src/components/TokenizedInput/i18n/index.ts new file mode 100644 index 00000000..115027aa --- /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}tokenized-input`); diff --git a/src/components/TokenizedInput/i18n/ru.json b/src/components/TokenizedInput/i18n/ru.json new file mode 100644 index 00000000..fdc8b880 --- /dev/null +++ b/src/components/TokenizedInput/i18n/ru.json @@ -0,0 +1,6 @@ +{ + "suggestions.items_not_found": "По запросу «{{text}}» ничего не найдено", + "clear_input": "Очистить поле", + "remove_token": "Удалить токен {{index}}", + "field_aria_label": "Поле {{key}} в токене {{index}}" +} diff --git a/src/components/TokenizedInput/index.ts b/src/components/TokenizedInput/index.ts new file mode 100644 index 00000000..35226dc3 --- /dev/null +++ b/src/components/TokenizedInput/index.ts @@ -0,0 +1,53 @@ +export { + useInputContext as useTokenizedInputContext, + useFocusContext as useTokenizedInputFocusContext, + useOptionsContext as useTokenizedInputOptionsContext, + useTokenizedInput, + useTokenizedInputComponents, +} from './context'; + +export { + WrapperComponent as TokenizedInputWrapperComponent, + useTokenizedInputWrapper, + TokenComponent as TokenizedInputTokenComponent, + useTokenizedInputNewToken, + useTokenizedInputRegularToken, + TokenListComponent as TokenizedInputListComponent, + useTokenizedInputList, + FieldComponent as TokenizedInputFieldComponent, + useTokenizedInputField, + SuggestionsComponent as TokenizedInputSuggestionsComponent, + useTokenizedInputSuggestions, +} from './components'; + +export type { + TokenizedInputFieldProps, + TokenizedInputSuggestionsProps, + TokenizedInputTokenProps, + SuggestionsListProps as TokenizedInputSuggestionsListProps, + SuggestionsNavigationOptions as TokenizedInputSuggestionsNavigationOptions, +} from './components'; + +export type { + TokenValueBase as TokenizedInputTokenValueBase, + RegularToken as TokenizedInputRegularToken, + NewToken as TokenizedInputNewToken, + Token as TokenizedInputToken, + TokenOnKeyDownOptions as TokenizedInputTokenOnKeyDownOptions, + TokenFieldKeyAction as TokenizedInputTokenFieldKeyAction, + TokenField as TokenizedInputTokenField, + TokenFocus as TokenizedInputTokenFocus, + TokenizedSuggestionContext as TokenizedInputSuggestionContext, + TokenizedSuggestionsItem as TokenizedInputSuggestionsItem, + TokenizedSuggestions as TokenizedInputSuggestions, + TokenPlaceholderGeneratorFn as TokenizedInputTokenPlaceholderGeneratorFn, + TokenizedInputInfo, + TokenizedInputFocusInfo, + TokenizedInputOptionsInfo, + TokenizedInputData, + TokenizedInputComposition, +} from './types'; + +export {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..297cda82 --- /dev/null +++ b/src/components/TokenizedInput/types.ts @@ -0,0 +1,274 @@ +import * as React from 'react'; + +import { + TokenizedInputFieldProps as FieldProps, + TokenizedInputSuggestionsProps as SuggestionsProps, + TokenizedInputTokenProps as TokenProps, +} from './components'; + +export type TokenValueBase = Record; + +export type RegularToken = { + id: string; + kind: 'regular'; + value: T; + options?: { + readOnlyFields?: (keyof T)[]; + notRemovable?: boolean; + }; + errors?: Partial>; +}; + +export type NewToken = { + id: string; + kind: 'new'; + value: T; + options?: undefined; + errors?: undefined; +}; + +export type Token = RegularToken | NewToken; + +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 type TokenizedInputOptionsInfo = { + /** Suggestions getter */ + 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; + /** Function to filter suggestions */ + filterSuggestions: ( + items: TokenizedSuggestionsItem[], + search: string, + ) => TokenizedSuggestionsItem[]; +}; + +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; + /** Gets 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; + /** Function to filter suggestions */ + filterSuggestions?: ( + items: TokenizedSuggestionsItem[], + search: string, + ) => TokenizedSuggestionsItem[]; +} + +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..4cfceab7 --- /dev/null +++ b/src/components/TokenizedInput/undoredo-manager.ts @@ -0,0 +1,117 @@ +import isEqual from 'lodash/isEqual'; + +const cloneValue = (value: T) => { + if (typeof structuredClone === 'function') { + return structuredClone(value); + } + + try { + return JSON.parse(JSON.stringify(value)); + } catch { + return value; + } +}; + +type UndoRedoState = { + value: T; + next: T[]; + prev: T[]; +}; + +export class UndoRedoManager { + _state: UndoRedoState | undefined = undefined; + + constructor(value: T) { + this._state = { + value: cloneValue(value), + next: [], + prev: [], + }; + } + + private get state() { + if (!this._state) { + return { + value: {} as T, + next: [], + prev: [], + }; + } + + return this._state; + } + + init(value: T) { + this._state = { + value: cloneValue(value), + next: [], + prev: [], + }; + } + + update(value: T, force?: boolean) { + if (isEqual(value, this.state.value)) { + return; + } + + const prev = force + ? [...this.state.prev] + : [...this.state.prev, cloneValue(this.state.value)]; + + this._state = { + value: cloneValue(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 = + typeof structuredClone === 'function' + ? structuredClone(this.state.value) + : JSON.parse(JSON.stringify(this.state.value)); + + this._state = { + ...this.state, + value: + typeof structuredClone === 'function' + ? structuredClone(prevState) + : JSON.parse(JSON.stringify(prevState)), + next: [...this.state.next, newNext], + }; + + return this.getValue(); + } + + redo(): T { + const nextState = this.state.next.pop(); + + if (!nextState) { + return this.getValue(); + } + + const newPrev = cloneValue(this.state.value); + + this._state = { + ...this.state, + value: cloneValue(nextState), + prev: [...this.state.prev, newPrev], + }; + + return this.getValue(); + } + + getValue(): T { + return cloneValue(this.state.value); + } +} diff --git a/src/components/TokenizedInput/utils.ts b/src/components/TokenizedInput/utils.ts new file mode 100644 index 00000000..8c589061 --- /dev/null +++ b/src/components/TokenizedInput/utils.ts @@ -0,0 +1,156 @@ +import {getUniqId} from '@gravity-ui/uikit'; + +import {Token, TokenField, TokenFieldKeyAction, TokenValueBase} from './types'; + +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.kind !== 'new'; + }); +}; + +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()}`, + kind: 'regular', + 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); + }, +}); + +export const tokenizedInputUtils = { + getDefaultTokenValue, + getValuesFromTokens, + removeEmptyTokens, + removeNewTokens, + defaultValidateToken, + defaultTransformTokens, + autoClosingPairsAction, +}; 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';