-
Notifications
You must be signed in to change notification settings - Fork 18
feat(TokenizedInput): Added new component TokenizedInput #375
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
0396b2d
feat: added tokenized input
873bfd1
fix: removed useless useeffect
4f78829
feat: added specific shortcuts for mac/windows+linux
0a1572b
chore: types
0250c14
chore: lint
5d3cbf2
fix: comments 1 part
f141cb9
fix: comments 2 part
6590c92
test(TokenizedInput): add comprehensive unit tests for all hooks
6a38610
chore: added tests
2df32e7
fix: fixed another comments
7661fe2
fix: cloneValue
5a5bf0f
fix: lint
ff1fe90
feat: added strategy fixed to field popup to prevent scroll
4e8f5a2
feat: added custom prop for filtering suggestions, removed fuzzy-sear…
0298581
fix: types
28a9a5f
chore: updated readme
f5d07cd
chore: updated codeowners
96d72b5
chore: updated reexports in index.ts
f2731a3
fix: types
8167b7d
fix: imports in stories
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<T>[]` | - | 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<T>[]` | - | Maps raw tokens to internal token shape. | | ||
| | `validateToken` | `(token: T) => Partial<Record<keyof T, string>> \| undefined \| false` | - | Validates a token. | | ||
| | `formatToken` | `(token: T) => T` | - | Formats a token value before saving. | | ||
| | `placeholder` | `string \| TokenizedInputTokenPlaceholderGeneratorFn<T>` | - | Placeholder for the new token. | | ||
| | `isEditable` | `boolean` | `true` | Whether editing is allowed. | | ||
| | `isClearable` | `boolean` | `true` | Whether full clear is allowed. | | ||
| | `debounceDelay` | `number \| Record<keyof T, number>` | `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<T>) => TokenizedInputSuggestions<T> \| Promise<TokenizedInputSuggestions<T>>` | - | Fetches suggestions. | | ||
| | `filterSuggestions` | `(items: TokenizedInputSuggestionsItem<T>[], search: string) => TokenizedInputSuggestionsItem<T>[]` | - | Custom function to filter suggestions based on search string. | | ||
| | `fullWidthSuggestions` | `boolean` | `false` | Render suggestions full width below the input. | | ||
| | `onKeyDown` | `(v: TokenizedInputTokenOnKeyDownOptions<T>) => 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<MyToken[]>([]); | ||
|
|
||
| return ( | ||
| <TokenizedInput<MyToken> | ||
| 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<TagToken>[] => { | ||
| 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<TagToken[]>([]); | ||
|
|
||
| return ( | ||
| <TokenizedInput | ||
| tokens={tokens} | ||
| transformTokens={transformTokens} | ||
| onChange={setTokens} | ||
| fields={fields} | ||
| /> | ||
| ); | ||
| } | ||
| ``` | ||
|
|
||
| #### 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; | ||
| }, []); | ||
|
|
||
| <TokenizedInput placeholder={placeholder} /* ... */ />; | ||
| ``` | ||
|
|
||
| ### 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 <span style={{color: 'purple', fontWeight: 800}}>{visibleValue}</span>; | ||
| } | ||
| if (fieldKey === 'value') { | ||
| return <span style={{color: 'green'}}>{visibleValue}</span>; | ||
| } | ||
|
|
||
| return visibleValue; | ||
| }; | ||
|
|
||
| // 2. Create a custom Field component that wraps the original Field | ||
| const CustomField = (props: TokenizedInputFieldProps) => { | ||
| return <TokenizedInput.Field {...props} renderValue={renderValue} />; | ||
| }; | ||
|
|
||
| // 3. Pass it to the Field prop | ||
| function App() { | ||
| return ( | ||
| <TokenizedInput | ||
| /* ...other props... */ | ||
| Field={CustomField} | ||
| /> | ||
| ); | ||
| } | ||
| ``` | ||
|
|
||
| #### 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) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For a 5500-line component, the README is quite brief. Missing:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Expanded the documentation significantly. Added usage examples with code snippets, a detailed description of the composition pattern (how to override sub-components), and separate shortcut lists for Mac and Windows/Linux