From d7f179b86ed8fdfa20812befefdb8a811b1caa12 Mon Sep 17 00:00:00 2001 From: Aleksei Gryzin Date: Fri, 10 Jul 2026 11:11:24 +0300 Subject: [PATCH] feat: added size for tokenized input --- src/components/TokenizedInput/README.md | 6 + .../TokenizedInput/TokenizedInput.scss | 131 ++++++++++++++---- .../__stories__/TokenizedInput.stories.tsx | 9 ++ .../components/Suggestions/Suggestions.tsx | 2 + .../Suggestions/SuggestionsList.tsx | 10 +- .../Suggestions/hooks/useSuggestions.ts | 4 +- .../components/Wrapper/hooks/useWrapper.ts | 14 +- .../context/TokenizedInputContext.tsx | 2 + .../hooks/useTokenizedInputInfo.ts | 3 + src/components/TokenizedInput/index.ts | 1 + src/components/TokenizedInput/types.ts | 6 + src/components/TokenizedInput/utils.ts | 30 +++- 12 files changed, 187 insertions(+), 31 deletions(-) diff --git a/src/components/TokenizedInput/README.md b/src/components/TokenizedInput/README.md index eb6d2d17..81906776 100644 --- a/src/components/TokenizedInput/README.md +++ b/src/components/TokenizedInput/README.md @@ -13,6 +13,8 @@ This component is for writing queries/filters and working with them as tokens. H | `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. | +| `className` | `string` | - | Wrapper className. | +| `size` | `'m' \| 'l' \| 'xl'` | `'m'` | Control size. | | `placeholder` | `string \| TokenizedInputTokenPlaceholderGeneratorFn` | - | Placeholder for the new token. | | `isEditable` | `boolean` | `true` | Whether editing is allowed. | | `isClearable` | `boolean` | `true` | Whether full clear is allowed. | @@ -27,6 +29,10 @@ This component is for writing queries/filters and working with them as tokens. H | `onBlur` | `() => void` | - | onBlur callback. | | `shouldAllowBlur` | `(e: React.FocusEvent) => boolean` | `() => true` | Return true to allow blur, false to prevent it. | +### Sizes + +The component supports sizes `m`, `l`, and `xl`. The default size is `m`. + ### Usage Examples #### 1. Basic Key-Value Input diff --git a/src/components/TokenizedInput/TokenizedInput.scss b/src/components/TokenizedInput/TokenizedInput.scss index 6ea7b375..7e0e8dfa 100644 --- a/src/components/TokenizedInput/TokenizedInput.scss +++ b/src/components/TokenizedInput/TokenizedInput.scss @@ -2,14 +2,22 @@ .gc-tokenized-input { &__wrapper { + --gc-tokenized-input-token-height: 24px; + --gc-tokenized-input-token-border-radius: var(--g-border-radius-s); + --gc-tokenized-input-clear-button-size: 24px; + --gc-tokenized-input-padding: 2px; + --gc-tokenized-input-field-padding-block: 3px; + --gc-tokenized-input-field-padding-inline: 4px; + --gc-tokenized-input-field-edge-padding-inline: 8px; + position: relative; display: flex; gap: 2px; width: 100%; - padding: 2px; - padding-inline-end: 34px; + padding: var(--gc-tokenized-input-padding); + padding-inline-end: calc(var(--gc-tokenized-input-clear-button-size) + 2px); border: 1px solid var(--g-color-line-generic); border-radius: var(--g-border-radius-l); @@ -36,19 +44,54 @@ &_focused { border-color: var(--g-color-line-generic-active); } + + &_size_l { + --gc-tokenized-input-token-height: 30px; + --gc-tokenized-input-token-border-radius: var(--g-border-radius-m); + --gc-tokenized-input-clear-button-size: 28px; + --gc-tokenized-input-field-padding-block: 6px; + --gc-tokenized-input-field-padding-inline: 6px; + --gc-tokenized-input-field-edge-padding-inline: 12px; + + font-size: var(--g-text-body-short-font-size); + line-height: var(--g-text-body-short-line-height); + } + + &_size_xl { + --gc-tokenized-input-token-height: 38px; + --gc-tokenized-input-token-border-radius: var(--g-border-radius-l); + --gc-tokenized-input-clear-button-size: 36px; + --gc-tokenized-input-field-padding-block: 9px; + --gc-tokenized-input-field-padding-inline: 6px; + --gc-tokenized-input-field-edge-padding-inline: 12px; + + border-radius: var(--g-border-radius-xl); + + font-size: var(--g-text-body-2-font-size); + line-height: var(--g-text-body-2-line-height); + } } &__clear-button { position: absolute; - inset-block-start: 0; - inset-inline-end: 0; + inset-block-start: calc( + var(--gc-tokenized-input-padding) + + ( + var(--gc-tokenized-input-token-height) - var( + --gc-tokenized-input-clear-button-size + ) + ) / + 2 + ); + inset-inline-end: var(--gc-tokenized-input-padding); display: flex; justify-content: center; align-items: center; - height: 100%; - padding: 3px 8px; + width: var(--gc-tokenized-input-clear-button-size); + height: var(--gc-tokenized-input-clear-button-size); + padding: 0; cursor: pointer; @@ -73,9 +116,9 @@ flex-wrap: nowrap; max-width: 100%; - height: 24px; + height: var(--gc-tokenized-input-token-height); - border-radius: var(--g-border-radius-s); + border-radius: var(--gc-tokenized-input-token-border-radius); background-color: var(--g-color-base-generic-accent-disabled); &_new { @@ -108,8 +151,8 @@ color: unset; border: unset; - border-start-end-radius: var(--g-border-radius-s); - border-end-end-radius: var(--g-border-radius-s); + border-start-end-radius: var(--gc-tokenized-input-token-border-radius); + border-end-end-radius: var(--gc-tokenized-input-token-border-radius); outline: none; background-color: unset; @@ -128,7 +171,8 @@ min-width: 1ch; max-width: 100%; height: 100%; - padding: 3px 4px; + padding: var(--gc-tokenized-input-field-padding-block) + var(--gc-tokenized-input-field-padding-inline); transition: color 0.2s; @@ -138,26 +182,26 @@ } &:first-child { - padding-inline-start: 8px; + padding-inline-start: var(--gc-tokenized-input-field-edge-padding-inline); - border-start-start-radius: var(--g-border-radius-s); - border-end-start-radius: var(--g-border-radius-s); + border-start-start-radius: var(--gc-tokenized-input-token-border-radius); + border-end-start-radius: var(--gc-tokenized-input-token-border-radius); .gc-tokenized-input__field-input { - inset-inline-start: 8px; + inset-inline-start: var(--gc-tokenized-input-field-edge-padding-inline); - width: calc(100% - 8px); + width: calc(100% - var(--gc-tokenized-input-field-edge-padding-inline)); } } &:last-child { - padding-inline-end: 8px; + padding-inline-end: var(--gc-tokenized-input-field-edge-padding-inline); - border-start-end-radius: var(--g-border-radius-s); - border-end-end-radius: var(--g-border-radius-s); + border-start-end-radius: var(--gc-tokenized-input-token-border-radius); + border-end-end-radius: var(--gc-tokenized-input-token-border-radius); .gc-tokenized-input__field-input { - width: calc(100% - 8px); + width: calc(100% - var(--gc-tokenized-input-field-edge-padding-inline)); } } @@ -200,9 +244,9 @@ &-input { position: absolute; inset-block-start: 0; - inset-inline-start: 4px; + inset-inline-start: var(--gc-tokenized-input-field-padding-inline); - width: calc(100% - 4px); + width: calc(100% - var(--gc-tokenized-input-field-padding-inline)); min-width: 2ch; height: 100%; padding: 0; @@ -257,6 +301,10 @@ &__suggestions-list { &-wrapper { + --gc-tokenized-input-suggestion-loading-height: 20px; + --gc-tokenized-input-suggestion-padding-block: 2px; + --gc-tokenized-input-suggestion-padding-inline: 12px; + display: flex; overflow-y: auto; flex-direction: column; @@ -267,7 +315,7 @@ &_loading { width: 300px; - height: 20px; + height: var(--gc-tokenized-input-suggestion-loading-height); overflow: hidden; } @@ -280,6 +328,40 @@ width: 100%; max-width: none; } + + &_size_l { + --gc-tokenized-input-suggestion-loading-height: 32px; + --gc-tokenized-input-suggestion-padding-block: 8px; + --gc-tokenized-input-suggestion-padding-inline: var(--g-spacing-4); + + font-size: var(--g-text-body-short-font-size); + line-height: var(--g-text-body-short-line-height); + } + + &_size_xl { + --gc-tokenized-input-suggestion-loading-height: 36px; + --gc-tokenized-input-suggestion-padding-block: 8px; + --gc-tokenized-input-suggestion-padding-inline: var(--g-spacing-4); + + font-size: var(--g-text-body-2-font-size); + line-height: var(--g-text-body-2-line-height); + } + + &_size_l, + &_size_xl { + &.gc-tokenized-input__suggestions-list-wrapper_empty { + .g-list__empty-placeholder { + min-height: 0; + padding: 0; + } + } + + &.gc-tokenized-input__suggestions-list-wrapper_loading { + .g-list__item { + height: var(--gc-tokenized-input-suggestion-loading-height); + } + } + } } &-item { @@ -287,7 +369,8 @@ width: 100%; margin: 0; - padding: 2px 12px; + padding: var(--gc-tokenized-input-suggestion-padding-block) + var(--gc-tokenized-input-suggestion-padding-inline); cursor: pointer; } diff --git a/src/components/TokenizedInput/__stories__/TokenizedInput.stories.tsx b/src/components/TokenizedInput/__stories__/TokenizedInput.stories.tsx index 4a86cfe2..5132f376 100644 --- a/src/components/TokenizedInput/__stories__/TokenizedInput.stories.tsx +++ b/src/components/TokenizedInput/__stories__/TokenizedInput.stories.tsx @@ -15,6 +15,15 @@ import {TokenizedComponentType} from './types'; const meta: Meta>> = { title: 'Components/TokenizedInput', component: TokenizedInput, + argTypes: { + size: { + options: ['m', 'l', 'xl'], + control: {type: 'radio'}, + }, + }, + args: { + size: 'm', + }, parameters: { disableStrictMode: true, }, diff --git a/src/components/TokenizedInput/components/Suggestions/Suggestions.tsx b/src/components/TokenizedInput/components/Suggestions/Suggestions.tsx index 4b4f74d9..d999ceeb 100644 --- a/src/components/TokenizedInput/components/Suggestions/Suggestions.tsx +++ b/src/components/TokenizedInput/components/Suggestions/Suggestions.tsx @@ -29,6 +29,7 @@ export function Suggestions({ popupWidth, popupOffset, fullWidthSuggestions, + size, } = suggestionsInfo.state; const {onApplySuggestion} = suggestionsInfo.callbacks; @@ -48,6 +49,7 @@ export function Suggestions({ onApplySuggestion={onApplySuggestion} selected={selected} fullWidth={fullWidthSuggestions} + size={size} /> ); }; diff --git a/src/components/TokenizedInput/components/Suggestions/SuggestionsList.tsx b/src/components/TokenizedInput/components/Suggestions/SuggestionsList.tsx index ef8147ba..276659f7 100644 --- a/src/components/TokenizedInput/components/Suggestions/SuggestionsList.tsx +++ b/src/components/TokenizedInput/components/Suggestions/SuggestionsList.tsx @@ -4,13 +4,19 @@ import {List} from '@gravity-ui/uikit'; import {b} from '../../constants'; import i18n from '../../i18n'; -import type {TokenValueBase, TokenizedSuggestions, TokenizedSuggestionsItem} from '../../types'; +import type { + TokenValueBase, + TokenizedInputSize, + TokenizedSuggestions, + TokenizedSuggestionsItem, +} from '../../types'; export type SuggestionsListProps = { selected: number; isLoading: boolean; suggestions: TokenizedSuggestions; fullWidth?: boolean; + size?: TokenizedInputSize; onApplySuggestion: (v: TokenizedSuggestionsItem) => void; }; @@ -19,6 +25,7 @@ export function SuggestionsList({ isLoading, suggestions, fullWidth, + size = 'm', onApplySuggestion, }: SuggestionsListProps) { const {items, options, currentWord} = suggestions; @@ -48,6 +55,7 @@ export function SuggestionsList({ empty: isEmpty && showEmptyState, loading: isLoading, 'full-width': fullWidth, + size, })} > ({ const focusInfo = useFocusContext(); const options = useOptionsContext(); - const {tokens} = inputInfo.state; + const {tokens, size} = inputInfo.state; const {onChangeToken, onApplyChanges} = inputInfo.callbacks; const {focus} = focusInfo.state; const {onFocus} = focusInfo.callbacks; @@ -234,6 +234,7 @@ export const useSuggestions = ({ popupWidth, popupOffset, fullWidthSuggestions, + size, }, callbacks: { onApplySuggestion, @@ -248,6 +249,7 @@ export const useSuggestions = ({ popupOffset, popupWidth, selected, + size, suggestions, ], ); diff --git a/src/components/TokenizedInput/components/Wrapper/hooks/useWrapper.ts b/src/components/TokenizedInput/components/Wrapper/hooks/useWrapper.ts index f3fad2b3..7e7a9d58 100644 --- a/src/components/TokenizedInput/components/Wrapper/hooks/useWrapper.ts +++ b/src/components/TokenizedInput/components/Wrapper/hooks/useWrapper.ts @@ -10,7 +10,7 @@ export const useWrapper = () => { const focusInfo = useFocusContext(); const inputInfo = useInputContext(); - const {tokens, fields, isEditable, isClearable, className, wrapperRef} = inputInfo.state; + const {tokens, fields, isEditable, isClearable, className, size, wrapperRef} = inputInfo.state; const {onApplyChanges, onClearInput} = inputInfo.callbacks; const {focus} = focusInfo.state; @@ -33,10 +33,18 @@ export const useWrapper = () => { const classNames = React.useMemo( () => ({ - wrapper: b('wrapper', {disabled: !isEditable, focused: Boolean(focus)}, className), + wrapper: b( + 'wrapper', + { + disabled: !isEditable, + focused: Boolean(focus), + size, + }, + className, + ), clearButton: b('clear-button'), }), - [className, focus, isEditable], + [className, focus, isEditable, size], ); return React.useMemo( diff --git a/src/components/TokenizedInput/context/TokenizedInputContext.tsx b/src/components/TokenizedInput/context/TokenizedInputContext.tsx index 1db08809..586dcba2 100644 --- a/src/components/TokenizedInput/context/TokenizedInputContext.tsx +++ b/src/components/TokenizedInput/context/TokenizedInputContext.tsx @@ -41,6 +41,7 @@ export function TokenizedInputContextProvider({ fields, placeholder, className, + size = 'm', isEditable, isClearable, onKeyDown, @@ -62,6 +63,7 @@ export function TokenizedInputContextProvider({ fields, placeholder, className, + size, isEditable, isClearable, onChange, diff --git a/src/components/TokenizedInput/hooks/useTokenizedInputInfo.ts b/src/components/TokenizedInput/hooks/useTokenizedInputInfo.ts index 4cc08e34..95fe2d85 100644 --- a/src/components/TokenizedInput/hooks/useTokenizedInputInfo.ts +++ b/src/components/TokenizedInput/hooks/useTokenizedInputInfo.ts @@ -30,6 +30,7 @@ export const useTokenizedInputInfo = ({ fields, placeholder, className, + size = 'm', onChange, }: UseTokenizedInputInfoOptions): TokenizedInputInfo => { const validateTokens = React.useCallback( @@ -208,6 +209,7 @@ export const useTokenizedInputInfo = ({ isClearable: shouldRenderClearButton, placeholder: isEditable ? placeholder : undefined, className, + size, }, callbacks: { onApplyChanges, @@ -226,6 +228,7 @@ export const useTokenizedInputInfo = ({ shouldRenderClearButton, placeholder, className, + size, onApplyChanges, onChangeToken, onChangeTokens, diff --git a/src/components/TokenizedInput/index.ts b/src/components/TokenizedInput/index.ts index 35226dc3..bcebd18b 100644 --- a/src/components/TokenizedInput/index.ts +++ b/src/components/TokenizedInput/index.ts @@ -46,6 +46,7 @@ export type { TokenizedInputOptionsInfo, TokenizedInputData, TokenizedInputComposition, + TokenizedInputSize, } from './types'; export {tokenizedInputUtils} from './utils'; diff --git a/src/components/TokenizedInput/types.ts b/src/components/TokenizedInput/types.ts index 91b9e1a7..110ec088 100644 --- a/src/components/TokenizedInput/types.ts +++ b/src/components/TokenizedInput/types.ts @@ -8,6 +8,8 @@ import { export type TokenValueBase = Record; +export type TokenizedInputSize = 'm' | 'l' | 'xl'; + export type RegularToken = { id: string; kind: 'regular'; @@ -144,6 +146,8 @@ export type TokenizedInputInfo = { placeholder?: string | TokenPlaceholderGeneratorFn; /** Wrapper className */ className?: string; + /** Control size */ + size?: TokenizedInputSize; /** Wrapper ref */ wrapperRef: React.RefObject; }; @@ -227,6 +231,8 @@ export interface TokenizedInputData { fields: TokenField[]; /** Wrapper className */ className?: string; + /** Control size */ + size?: TokenizedInputSize; /** Placeholder for the new token */ placeholder?: string | TokenPlaceholderGeneratorFn; /** Whether editing is allowed */ diff --git a/src/components/TokenizedInput/utils.ts b/src/components/TokenizedInput/utils.ts index 8c589061..8637b940 100644 --- a/src/components/TokenizedInput/utils.ts +++ b/src/components/TokenizedInput/utils.ts @@ -1,6 +1,6 @@ -import {getUniqId} from '@gravity-ui/uikit'; +import {type ButtonProps, getUniqId} from '@gravity-ui/uikit'; -import {Token, TokenField, TokenFieldKeyAction, TokenValueBase} from './types'; +import {Token, TokenField, TokenFieldKeyAction, TokenValueBase, TokenizedInputSize} from './types'; export const getDefaultTokenValue = (fields: TokenField[]): T => { return fields.reduce((acc, cur) => ({...acc, [cur.key]: ''}), {} as T); @@ -50,6 +50,30 @@ export const defaultTransformTokens = (tokens: T[]): T }); }; +const TOKENIZED_INPUT_CLEAR_BUTTON_SIZE: Record = { + m: 's', + l: 'm', + xl: 'l', +}; + +const TOKENIZED_INPUT_REMOVE_BUTTON_SIZE: Record = { + m: 'm', + l: 'm', + xl: 'l', +}; + +export const getTokenizedInputClearButtonSize = ( + size: TokenizedInputSize = 'm', +): ButtonProps['size'] => { + return TOKENIZED_INPUT_CLEAR_BUTTON_SIZE[size]; +}; + +export const getTokenizedInputRemoveButtonSize = ( + size: TokenizedInputSize = 'm', +): ButtonProps['size'] => { + return TOKENIZED_INPUT_REMOVE_BUTTON_SIZE[size]; +}; + const findPairBySymbol = (symbol: string, pairs: Record) => { const pair = Object.entries(pairs).find(([o, c]) => o === symbol || c === symbol); @@ -153,4 +177,6 @@ export const tokenizedInputUtils = { defaultValidateToken, defaultTransformTokens, autoClosingPairsAction, + getTokenizedInputClearButtonSize, + getTokenizedInputRemoveButtonSize, };