Skip to content

Commit e861428

Browse files
authored
feat(TokenizedInput): Added new component TokenizedInput (#375)
Co-authored-by: Alexey Gryzin <>
1 parent 0e1fd67 commit e861428

91 files changed

Lines changed: 8813 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@
1414
/src/components/Stories @darkgenius
1515
/src/components/ConfirmDialog @kseniya57
1616
/src/components/Gallery @kseniya57
17+
/src/components/TokenizedInput @feelsbadmans
1718
/src/hooks/useGallery @kseniya57
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
## TokenizedInput
2+
3+
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).
4+
5+
### API Reference
6+
7+
| Prop | Type | Default | Description |
8+
| :---------------------- | :------------------------------------------------------------------------------------------------------------------- | :-------------- | :------------------------------------------------------------- |
9+
| `tokens` | `T[]` | - | Array of token values. |
10+
| `fields` | `TokenizedInputTokenField<T>[]` | - | Field definitions; order matches display order. |
11+
| `onChange` | `(newTokens: T[]) => void` | - | Token list change handler. |
12+
| `defaultTokens` | `T[]` | `[]` | Defaults applied on full clear. |
13+
| `transformTokens` | `(tokens: T[]) => TokenizedInputToken<T>[]` | - | Maps raw tokens to internal token shape. |
14+
| `validateToken` | `(token: T) => Partial<Record<keyof T, string>> \| undefined \| false` | - | Validates a token. |
15+
| `formatToken` | `(token: T) => T` | - | Formats a token value before saving. |
16+
| `placeholder` | `string \| TokenizedInputTokenPlaceholderGeneratorFn<T>` | - | Placeholder for the new token. |
17+
| `isEditable` | `boolean` | `true` | Whether editing is allowed. |
18+
| `isClearable` | `boolean` | `true` | Whether full clear is allowed. |
19+
| `debounceDelay` | `number \| Record<keyof T, number>` | `150` | Suggestions debounce delay; per-field overrides are supported. |
20+
| `debounceFlushStrategy` | `'focus-input' \| 'focus-field'` | `'focus-field'` | When debounce flushes. |
21+
| `autoFocus` | `boolean` | `false` | Autofocus the new token. |
22+
| `onSuggest` | `(ctx: TokenizedInputSuggestionContext<T>) => TokenizedInputSuggestions<T> \| Promise<TokenizedInputSuggestions<T>>` | - | Fetches suggestions. |
23+
| `filterSuggestions` | `(items: TokenizedInputSuggestionsItem<T>[], search: string) => TokenizedInputSuggestionsItem<T>[]` | - | Custom function to filter suggestions based on search string. |
24+
| `fullWidthSuggestions` | `boolean` | `false` | Render suggestions full width below the input. |
25+
| `onKeyDown` | `(v: TokenizedInputTokenOnKeyDownOptions<T>) => boolean` | - | Keydown handler; return true to stop further handling. |
26+
| `onFocus` | `() => void` | - | onFocus callback. |
27+
| `onBlur` | `() => void` | - | onBlur callback. |
28+
| `shouldAllowBlur` | `(e: React.FocusEvent) => boolean` | `() => true` | Return true to allow blur, false to prevent it. |
29+
30+
### Usage Examples
31+
32+
#### 1. Basic Key-Value Input
33+
34+
The most common use case is a query builder where each token consists of a `key`, `operator`, and `value`.
35+
36+
```tsx
37+
import {TokenizedInput} from '@gravity-ui/components';
38+
39+
type MyToken = {
40+
key: string;
41+
operator: string;
42+
value: string;
43+
};
44+
45+
const fields = [
46+
{key: 'key', className: 'my-key-field'},
47+
{key: 'operator', className: 'my-operator-field'},
48+
{key: 'value', className: 'my-value-field'},
49+
];
50+
51+
function App() {
52+
const [tokens, setTokens] = React.useState<MyToken[]>([]);
53+
54+
return (
55+
<TokenizedInput<MyToken>
56+
tokens={tokens}
57+
fields={fields}
58+
onChange={setTokens}
59+
onSuggest={async ({key, value}) => {
60+
// Return suggestions based on the current field
61+
if (key === 'key') {
62+
return {
63+
items: [
64+
{
65+
label: 'Status',
66+
search: 'Status',
67+
value: {key: 'Status'},
68+
focus: {idx: 0, key: 'operator', offset: -1},
69+
},
70+
{
71+
label: 'User',
72+
search: 'User',
73+
value: {key: 'User'},
74+
focus: {idx: 0, key: 'operator', offset: -1},
75+
},
76+
],
77+
};
78+
}
79+
return {items: []};
80+
}}
81+
/>
82+
);
83+
}
84+
```
85+
86+
#### 2. Single Field (Tags) Input
87+
88+
You can use `TokenizedInput` as a simple tags input by defining only one field and using `transformTokens` to make existing tokens read-only.
89+
90+
```tsx
91+
import {getUniqId} from '@gravity-ui/uikit';
92+
import {TokenizedInput, TokenizedInputToken} from '@gravity-ui/components';
93+
94+
type TagToken = {value: string};
95+
96+
const fields = [
97+
{
98+
key: 'value',
99+
specialKeysActions: [
100+
{
101+
// Create a new token when pressing Space
102+
key: (e) => e.key === ' ',
103+
action: ({focus, onFocus, event}) => {
104+
event.preventDefault();
105+
onFocus({...focus, idx: focus.idx + 1, key: 'value', offset: -1});
106+
},
107+
},
108+
],
109+
},
110+
];
111+
112+
// Make existing tokens read-only so they act like solid blocks (tags)
113+
const transformTokens = (tokens: TagToken[]): TokenizedInputToken<TagToken>[] => {
114+
return tokens.map((t) => ({
115+
id: getUniqId(),
116+
value: t,
117+
kind: 'regular',
118+
options: {readOnlyFields: ['value']}, // Prevents editing the text inside the tag
119+
}));
120+
};
121+
122+
function TagsInput() {
123+
const [tokens, setTokens] = React.useState<TagToken[]>([]);
124+
125+
return (
126+
<TokenizedInput
127+
tokens={tokens}
128+
transformTokens={transformTokens}
129+
onChange={setTokens}
130+
fields={fields}
131+
/>
132+
);
133+
}
134+
```
135+
136+
#### 3. Dynamic Placeholders
137+
138+
You can provide a function to the `placeholder` prop to generate context-aware placeholders based on the token's current state.
139+
140+
```tsx
141+
const placeholder = React.useCallback((tokenType, token, idx) => {
142+
// Show a specific placeholder when the user is about to type a value for the "message" key
143+
if (token.key === 'message' && idx === 2) {
144+
return 'Enter a string';
145+
}
146+
// Show a general placeholder for the very first empty token
147+
if (tokenType === 'new' && idx === 0) {
148+
return 'Enter a value';
149+
}
150+
return undefined;
151+
}, []);
152+
153+
<TokenizedInput placeholder={placeholder} /* ... */ />;
154+
```
155+
156+
### Composition Pattern
157+
158+
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.
159+
160+
The available sub-components are:
161+
162+
- `Wrapper`: The outermost container that handles global key presses and blur events.
163+
- `TokenList`: Renders the list of tokens.
164+
- `Token`: Renders an individual token (either a `RegularToken` or a `NewToken`).
165+
- `Field`: Renders the actual input field inside a token.
166+
- `Suggestions`: Renders the suggestions popup.
167+
168+
#### Overriding Sub-components
169+
170+
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.
171+
172+
```tsx
173+
import {TokenizedInput, TokenizedInputFieldProps} from '@gravity-ui/components';
174+
175+
// 1. Define your custom render logic
176+
const renderValue: TokenizedInputFieldProps['renderValue'] = ({
177+
fieldKey,
178+
isFocused,
179+
isNew,
180+
visibleValue,
181+
}) => {
182+
// Don't format while typing
183+
if (isNew || isFocused) {
184+
return visibleValue;
185+
}
186+
187+
// Apply custom formatting based on the field type
188+
if (fieldKey === 'key') {
189+
return <span style={{color: 'purple', fontWeight: 800}}>{visibleValue}</span>;
190+
}
191+
if (fieldKey === 'value') {
192+
return <span style={{color: 'green'}}>{visibleValue}</span>;
193+
}
194+
195+
return visibleValue;
196+
};
197+
198+
// 2. Create a custom Field component that wraps the original Field
199+
const CustomField = (props: TokenizedInputFieldProps) => {
200+
return <TokenizedInput.Field {...props} renderValue={renderValue} />;
201+
};
202+
203+
// 3. Pass it to the Field prop
204+
function App() {
205+
return (
206+
<TokenizedInput
207+
/* ...other props... */
208+
Field={CustomField}
209+
/>
210+
);
211+
}
212+
```
213+
214+
#### Context Hooks
215+
216+
When building fully custom sub-components, you can use the provided context hooks to access the internal state and callbacks of the `TokenizedInput`:
217+
218+
- `useTokenizedInputContext()` — Input state and callbacks (`tokens`, `fields`, `onChangeToken`, `onRemoveToken`, etc.)
219+
- `useTokenizedInputFocusContext()` — Focus state and callbacks (`focus`, `onFocus`, `onBlur`, `getFocusRules`, etc.)
220+
- `useTokenizedInputOptionsContext()` — Extra options from props (`onSuggest`, `debounceDelay`, `shouldAllowBlur`, etc.)
221+
- `useTokenizedInput()` — Returns all three contexts above combined (`inputInfo`, `focusInfo`, `options`).
222+
- `useTokenizedInputComponents()` — Access to the current sub-components (useful if your custom component needs to render the default `Field` or `Token`).
223+
224+
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`.
225+
226+
### Hotkeys
227+
228+
#### Mac
229+
230+
- `Cmd + Arrow` — move between tokens
231+
- `Option + Arrow` — move between token fields
232+
- `Cmd + Backspace` — delete the current token
233+
- `Cmd + Z` — undo
234+
- `Cmd + Shift + Z` — redo
235+
- `Cmd + I` — open the suggestions menu
236+
- `Cmd + Enter` — finish the current token and go to the next (when the suggestions menu is closed)
237+
238+
#### Windows / Linux
239+
240+
- `Ctrl + Alt + Arrow` — move between tokens
241+
- `Ctrl + Arrow` — move between token fields
242+
- `Ctrl + Alt + Backspace` — delete the current token
243+
- `Ctrl + Z` — undo
244+
- `Ctrl + Y` or `Ctrl + Shift + Z` — redo
245+
- `Ctrl + I` — open the suggestions menu
246+
- `Ctrl + Enter` — finish the current token and go to the next (when the suggestions menu is closed)
247+
248+
#### General
249+
250+
- `Escape` — close the suggestions menu; press again to remove focus
251+
- `Enter` — select a suggestion / finish the current token and go to the next (when the suggestions menu is closed)

0 commit comments

Comments
 (0)