/
kaws
/
ui-kit-ce
Обзор
Документация
Войти
/
kaws
/
ui-kit-ce
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
packages/tag/src/TagInput.tsx
589 строк
16 KB
Denis Svyatovets
fix(tag): исправлена работа tagInput в safari
27 окт 2025, 12:21
27 окт 2025, 12:21
7517120
Код
Авторство
О чём код?
'use client' import * as React from 'react' import { clsx, createUseStyles } from '@v-uik/theme' import { useClassList, useMergedRefs, useButtonReset } from '@v-uik/hooks' import { useText } from '@v-uik/typography' import { isEqualKeyboardKeys } from '@v-uik/utils' import { TagInputClasses } from './interfaces' import { TTagElementSizeType, TagElementSize } from './TTagElementSizeType' import { getTagInputComponents, TagInputComponentsConfig } from './components' import type { ComponentPropsWithRefFix, ComponentPropsWithAttributes, } from '@v-uik/common' export interface TagInputProps extends Omit< ComponentPropsWithRefFix<'div'>, 'prefix' | 'onChange' | 'onSubmit' > { /** * Список классов. * * @inner */ classes?: Partial<TagInputClasses> /** * HTML-атрибуты нативного элемента input. * * @inner * * @param inputProps.onBlur - Обработчик потери фокуса с элемента. * @param inputProps.onFocus — обработчик onFocus. * @param inputProps.maxLength — максимальная длина вводимого значения. * @param inputProps — остальные нативные атрибуты элемента input. */ inputProps?: ComponentPropsWithAttributes<ComponentPropsWithRefFix<'input'>> /** * Ссылка на нативный элемент input. */ inputRef?: | React.RefCallback<HTMLInputElement> | React.MutableRefObject<HTMLInputElement | null> | null /** * Поле заблокировано для ввода. * * @inner */ disabled?: boolean /** * Размер поля. * * @inner */ size?: TTagElementSizeType /** * Подсказка внутри поля, если не введен текст. * * @inner */ placeholder?: string /** * Значение поля. * * @inner */ value?: React.ReactText /** * Обработчик события изменения значения поля. * * @returns */ onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void /** * Обработчик события обновления значения поля. * * @returns */ onSubmit?: (value: string) => void /** * Свойство для переопределения элементов Tag Input. * * @inner */ components?: TagInputComponentsConfig } const useStyles = createUseStyles((theme) => ({ container: { position: 'relative', display: 'inline-flex', alignItems: 'center', boxSizing: 'border-box', '&::after': { content: '""', position: 'absolute', top: 0, bottom: 0, left: 0, right: 0, zIndex: 1, borderRadius: 'inherit', borderColor: theme.comp.tagInput.containerColorBorder, borderStyle: 'dashed', borderWidth: theme.comp.tagInput.containerWidthBorder, }, '&$disabled': { '&::after': { borderColor: theme.comp.tagInput.containerColorBorderDisabled, }, }, '&:hover:not($disabled)': { backgroundColor: theme.comp.tagInput.containerColorBackgroundHover, }, '&$focused': { boxShadow: `0 0 0 ${theme.comp.tagInput.containerWidthShadowFocus}px ${theme.comp.tagInput.containerColorBorderFocus}`, '&::after': { borderWidth: 0, }, }, '&$focused:hover': { backgroundColor: 'transparent', }, '& > $addIcon': { position: 'absolute', top: '50%', marginTop: -8, right: 8, zIndex: 2, cursor: 'pointer', }, }, focused: {}, disabled: {}, extraSmall: { '& $tag': { padding: [2, 8], }, '& $text': { '& $addIcon': { marginLeft: 4, }, }, '& > $addIcon': { right: 4, }, borderTopLeftRadius: theme.comp.tagInput.shapeBorderRadiusTopLeftXs || theme.comp.tagInput.shapeBorderRadiusTopLeft, borderTopRightRadius: theme.comp.tagInput.shapeBorderRadiusTopRightXs || theme.comp.tagInput.shapeBorderRadiusTopRight, borderBottomLeftRadius: theme.comp.tagInput.shapeBorderRadiusBottomLeftXs || theme.comp.tagInput.shapeBorderRadiusBottomLeft, borderBottomRightRadius: theme.comp.tagInput.shapeBorderRadiusBottomRightXs || theme.comp.tagInput.shapeBorderRadiusBottomRight, '&$focused': { borderTopLeftRadius: theme.comp.tagInput.shapeBorderRadiusTopLeftFocusedXs || theme.comp.tagInput.shapeBorderRadiusTopLeftFocused, borderTopRightRadius: theme.comp.tagInput.shapeBorderRadiusTopRightFocusedXs || theme.comp.tagInput.shapeBorderRadiusTopRightFocused, borderBottomLeftRadius: theme.comp.tagInput.shapeBorderRadiusBottomLeftFocusedXs || theme.comp.tagInput.shapeBorderRadiusBottomLeftFocused, borderBottomRightRadius: theme.comp.tagInput.shapeBorderRadiusBottomRightFocusedXs || theme.comp.tagInput.shapeBorderRadiusBottomRightFocused, }, }, small: { '& $tag': { padding: [2, 8], }, '& $text': { '& $addIcon': { marginLeft: 4, }, }, '& > $addIcon': { right: 4, }, borderTopLeftRadius: theme.comp.tagInput.shapeBorderRadiusTopLeftSm || theme.comp.tagInput.shapeBorderRadiusTopLeft, borderTopRightRadius: theme.comp.tagInput.shapeBorderRadiusTopRightSm || theme.comp.tagInput.shapeBorderRadiusTopRight, borderBottomLeftRadius: theme.comp.tagInput.shapeBorderRadiusBottomLeftSm || theme.comp.tagInput.shapeBorderRadiusBottomLeft, borderBottomRightRadius: theme.comp.tagInput.shapeBorderRadiusBottomRightSm || theme.comp.tagInput.shapeBorderRadiusBottomRight, '&$focused': { borderTopLeftRadius: theme.comp.tagInput.shapeBorderRadiusTopLeftFocusedSm || theme.comp.tagInput.shapeBorderRadiusTopLeftFocused, borderTopRightRadius: theme.comp.tagInput.shapeBorderRadiusTopRightFocusedSm || theme.comp.tagInput.shapeBorderRadiusTopRightFocused, borderBottomLeftRadius: theme.comp.tagInput.shapeBorderRadiusBottomLeftFocusedSm || theme.comp.tagInput.shapeBorderRadiusBottomLeftFocused, borderBottomRightRadius: theme.comp.tagInput.shapeBorderRadiusBottomRightFocusedSm || theme.comp.tagInput.shapeBorderRadiusBottomRightFocused, }, }, medium: { '& $tag': { padding: [6, 8, 6, 12], }, borderTopLeftRadius: theme.comp.tagInput.shapeBorderRadiusTopLeftMd || theme.comp.tagInput.shapeBorderRadiusTopLeft, borderTopRightRadius: theme.comp.tagInput.shapeBorderRadiusTopRightMd || theme.comp.tagInput.shapeBorderRadiusTopRight, borderBottomLeftRadius: theme.comp.tagInput.shapeBorderRadiusBottomLeftMd || theme.comp.tagInput.shapeBorderRadiusBottomLeft, borderBottomRightRadius: theme.comp.tagInput.shapeBorderRadiusBottomRightMd || theme.comp.tagInput.shapeBorderRadiusBottomRight, '&$focused': { borderTopLeftRadius: theme.comp.tagInput.shapeBorderRadiusTopLeftFocusedMd || theme.comp.tagInput.shapeBorderRadiusTopLeftFocused, borderTopRightRadius: theme.comp.tagInput.shapeBorderRadiusTopRightFocusedMd || theme.comp.tagInput.shapeBorderRadiusTopRightFocused, borderBottomLeftRadius: theme.comp.tagInput.shapeBorderRadiusBottomLeftFocusedMd || theme.comp.tagInput.shapeBorderRadiusBottomLeftFocused, borderBottomRightRadius: theme.comp.tagInput.shapeBorderRadiusBottomRightFocusedMd || theme.comp.tagInput.shapeBorderRadiusBottomRightFocused, }, }, large: { '& $tag': { padding: [6, 8, 6, 12], }, borderTopLeftRadius: theme.comp.tagInput.shapeBorderRadiusTopLeftLg || theme.comp.tagInput.shapeBorderRadiusTopLeft, borderTopRightRadius: theme.comp.tagInput.shapeBorderRadiusTopRightLg || theme.comp.tagInput.shapeBorderRadiusTopRight, borderBottomLeftRadius: theme.comp.tagInput.shapeBorderRadiusBottomLeftLg || theme.comp.tagInput.shapeBorderRadiusBottomLeft, borderBottomRightRadius: theme.comp.tagInput.shapeBorderRadiusBottomRightLg || theme.comp.tagInput.shapeBorderRadiusBottomRight, '&$focused': { borderTopLeftRadius: theme.comp.tagInput.shapeBorderRadiusTopLeftFocusedLg || theme.comp.tagInput.shapeBorderRadiusTopLeftFocused, borderTopRightRadius: theme.comp.tagInput.shapeBorderRadiusTopRightFocusedLg || theme.comp.tagInput.shapeBorderRadiusTopRightFocused, borderBottomLeftRadius: theme.comp.tagInput.shapeBorderRadiusBottomLeftFocusedLg || theme.comp.tagInput.shapeBorderRadiusBottomLeftFocused, borderBottomRightRadius: theme.comp.tagInput.shapeBorderRadiusBottomRightFocusedLg || theme.comp.tagInput.shapeBorderRadiusBottomRightFocused, }, }, tag: { width: '100%', padding: [6, 8, 6, 12], cursor: 'pointer', color: theme.comp.tagInput.tagColorText, display: 'flex', alignItems: 'center', zIndex: 2, '&:disabled': { cursor: 'default', color: theme.comp.tagInput.tagColorTextDisabled, '& $addIcon': { color: theme.comp.tagInput.iconColorTextDisabled, }, }, }, text: { fontFamily: theme.comp.tagInput.typographyFontFamily, fontWeight: theme.comp.tagInput.typographyFontWeight, letterSpacing: theme.comp.tagInput.typographyLetterSpacing, width: '100%', display: 'flex', alignItems: 'center', flexWrap: 'nowrap', '& $addIcon': { flexShrink: 0, marginLeft: 8, }, }, textExtraSmall: { fontSize: theme.comp.tagInput.typographyFontSizeXs || theme.comp.tagInput.typographyFontSize, lineHeight: theme.comp.tagInput.typographyLineHeightXs || theme.comp.tagInput.typographyLineHeight, }, textSmall: { fontSize: theme.comp.tagInput.typographyFontSizeSm || theme.comp.tagInput.typographyFontSize, lineHeight: theme.comp.tagInput.typographyLineHeightSm || theme.comp.tagInput.typographyLineHeight, }, textMedium: { fontSize: theme.comp.tagInput.typographyFontSizeMd || theme.comp.tagInput.typographyFontSize, lineHeight: theme.comp.tagInput.typographyLineHeightMd || theme.comp.tagInput.typographyLineHeight, }, textLarge: { fontSize: theme.comp.tagInput.typographyFontSizeLg || theme.comp.tagInput.typographyFontSize, lineHeight: theme.comp.tagInput.typographyLineHeightLg || theme.comp.tagInput.typographyLineHeight, }, addIcon: { color: theme.comp.tagInput.iconColorText, }, input: { width: '100%', margin: 0, outline: 0, border: 0, boxShadow: 'none', borderRadius: 'inherit', color: theme.comp.tagInput.inputColorText, backgroundColor: theme.comp.tagInput.inputColorBackground, fontFamily: theme.comp.tagInput.typographyFontFamily, fontWeight: theme.comp.tagInput.typographyFontWeight, letterSpacing: theme.comp.tagInput.typographyLetterSpacing, '&::placeholder': { color: theme.comp.tagInput.inputPlaceholderColorText, }, '&:focus': { outline: 0, }, }, inputExtraSmall: { padding: [2, 24, 2, 8], fontSize: theme.comp.tagInput.typographyFontSizeXs || theme.comp.tagInput.typographyFontSize, lineHeight: theme.comp.tagInput.typographyLineHeightXs || theme.comp.tagInput.typographyLineHeight, }, inputSmall: { padding: [2, 24, 2, 8], fontSize: theme.comp.tagInput.typographyFontSizeSm || theme.comp.tagInput.typographyFontSize, lineHeight: theme.comp.tagInput.typographyLineHeightSm || theme.comp.tagInput.typographyLineHeight, }, inputMedium: { padding: [6, 28, 6, 12], fontSize: theme.comp.tagInput.typographyFontSizeMd || theme.comp.tagInput.typographyFontSize, lineHeight: theme.comp.tagInput.typographyLineHeightMd || theme.comp.tagInput.typographyLineHeight, }, inputLarge: { padding: [6, 28, 6, 12], fontSize: theme.comp.tagInput.typographyFontSizeLg || theme.comp.tagInput.typographyFontSize, lineHeight: theme.comp.tagInput.typographyLineHeightLg || theme.comp.tagInput.typographyLineHeight, }, empty: { '& + $addIcon': { color: theme.comp.tagInput.iconColorTextFocusEmpty, }, }, })) export const TagInput = React.forwardRef( ( { classes, className, style, value, onChange, placeholder, inputProps, inputRef: inputRefProp = null, disabled, size = TagElementSize.md, children, onClick, onSubmit, components, ...rest }: TagInputProps, ref: React.Ref<HTMLDivElement> ) => { const [focused, setFocused] = React.useState(false) const width = React.useRef<number>() const { AddIcon } = getTagInputComponents(components) const innerRef = React.useRef<HTMLDivElement>(null) const inputRef = React.useRef<HTMLInputElement>(null) const mergedInnerRef = useMergedRefs([ref, innerRef]) const mergedInputRef = useMergedRefs([inputRefProp, inputRef]) const buttonClasses = useButtonReset() const { ellipsis } = useText() const classesList = useStyles() const classesMap = useClassList(classesList, classes) const isExtraSmall = size === TagElementSize.xs const isSmall = size === TagElementSize.sm const isMedium = size === TagElementSize.md const isLarge = size === TagElementSize.lg const containerClassName = clsx(classesMap.container, className, { [classesMap.focused]: focused, [classesMap.disabled]: disabled, [classesMap.extraSmall]: isExtraSmall, [classesMap.small]: isSmall, [classesMap.medium]: isMedium, [classesMap.large]: isLarge, }) const inputClassName = clsx(classesMap.input, { [classesMap.empty]: typeof value !== 'number' && !value?.trim(), [classesMap.inputExtraSmall]: isExtraSmall, [classesMap.inputSmall]: isSmall, [classesMap.inputMedium]: isMedium, [classesMap.inputLarge]: isLarge, }) const textClassName = clsx(ellipsis, classesMap.text, { [classesMap.textExtraSmall]: isExtraSmall, [classesMap.textSmall]: isSmall, [classesMap.textMedium]: isMedium, [classesMap.textLarge]: isLarge, }) React.useEffect(() => { if (focused) { inputRef.current?.focus() } }) const handleFocusButton = () => { width.current = innerRef.current?.getBoundingClientRect().width setFocused(true) } const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => { setFocused(false) if (inputProps && inputProps.onBlur) { inputProps.onBlur(event) } } const submitValue = () => { if (inputRef.current?.value) { onSubmit?.(inputRef.current?.value) } } const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { inputProps?.onKeyDown?.(event) if (isEqualKeyboardKeys('Enter', event.key)) { submitValue() } } return ( <div {...rest} ref={mergedInnerRef} style={{ width: focused ? width.current : undefined, ...style, }} className={containerClassName} > {!focused && ( <button type="button" disabled={disabled} className={clsx(buttonClasses.resetButton, classesMap.tag)} // требуется явное указание tabIndex для коррректной работы в safari tabIndex={0} onFocus={handleFocusButton} > <span className={textClassName}> {children} <AddIcon className={classesMap.addIcon} /> </span> </button> )} {focused && ( <> <input {...inputProps} ref={mergedInputRef} className={inputClassName} disabled={disabled} placeholder={placeholder} value={value} onChange={onChange} onBlur={handleBlur} onKeyDown={onKeyDown} /> <AddIcon aria-label="submit" className={classesMap.addIcon} onMouseDown={submitValue} /> </> )} </div> ) } )