Задача
Необходимо сделать input с помощью React, в котором, после текста отображается какое то значение. Будем называть это значение суффиксом.
Условия
Cуффикс не должен подмешиваться к самому значению инпута, т.e. чтобы мы на каждый change эвент не брали строку и не отделяли этот суффикс, а потом все снова складывали
Суффикс во время ввода должен всегда быть виден
Суффикс может быть другим react элементом (например картинкой, или текстом)
Если мы передадим во время работы приложения новое значение пропа суффикса -- он должен нормально перерендериться, инпут не должен сломаться
Суффикс нельзя выделить, скопировать, как либо с ним провзаимодействовать. Он не должен перекрывать поле инпута
Поведение инпута никак не должно отличаться от обычного
Какой результат мы получим в конце статьи
Пример на codesandbox, который можно потыкать
Что я буду использовать в проекте
React
Typescript
SCSS - для удобства описания стилей
clsx - утилита для условного построения строк className
Начнинаем
Создаем компонент Input.tsx
export type InputProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
'style'
> & {
suffix?: ReactNode
}
export const Input: FC<InputProps> = ({
value,
placeholder,
className,
suffix,
...props
}) => {
return (
<div className={styles.inputWrapper}>
<input
className={clsx(styles.input, className)}
value={value}
placeholder={placeholder}
{...props}
/>
</div>
)
}
Благодаря InputHTMLAttributes<HTMLInputElement>
наш компонент будет ожидать те же самые пропы, как и сам input. Выделим сразу value и placeholder, они нам понадобятся. Сразу обернем инпут в div с классом inputWrapper, от него мы будем позиционировать наш suffix. Установим ему position: relative;
.
С помощью пропа suffix мы будем передавать наш суффикс
Стили inputWrapper и input
.inputWrapper {
display: flex;
width: 400px;
height: 80px;
position: relative;
font-size: 30px;
line-height: 32px;
font-family: sans-serif;
}
.input {
font-size: inherit;
line-height: inherit;
font-family: inherit;
width: 100%;
background-color: #FFFFFF;
outline: none;
border: 1px solid #007BFF;
border-radius: 10px;
}
Добавим код под input
<div className={styles.inputFakeValueWrapper}>
<span className={styles.inputFakeValue}>{value || placeholder}</span>
<span ref={suffixRef} className={styles.suffix}>
{suffix}
</span>
</div>
В чем заключается идея этого кода -- span c классом inputFakeValue будет полностью повторять значение input, а сразу после него будет идти наш suffix. Т.e. по мере увеличения ввода, inputFakeValue будет расширятся и отталкивать suffix. При этом текст в inputFakeValue должен быть полностью идентичен как стилем шрифта так и размером и line-height c текстом в input.
Установим свойство pointer-events в значение none для стиля inputFakeValueWrapper чтобы все элементы в нем находящиеся не перехватывали события браузера. pointer-events: none;
Так же установим свойство visibility в значение hidden для стиля inputFakeValueWrapper для того чтобы наш текст был скрыт и не наслаивался на сам input. visibility: hidden;
. А для suffix visibility: visible;
-- соответственно чтобы суффикс было видно
top: 0; left: 0; bottom: 0; right: 0;
в inputFakeValueWrapper установлены вместе с position: absolute;
чтобы наш элемент полностью растягивался по inputWrapper
Стили inputFakeValueWrapper, inputFakeValue, suffix
.inputFakeValueWrapper {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
display: flex;
align-items: center;
visibility: hidden;
user-select: none;
pointer-events: none;
font-size: inherit;
line-height: inherit;
font-family: inherit;
}
.inputFakeValue {
overflow: hidden;
}
.suffix {
visibility: visible;
height: 100%;
display: flex;
align-items: center;
}
Теперь наш код выглядит так
export type InputProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
'style'
> & {
suffix?: ReactNode
}
export const Input: FC<InputProps> = ({
value,
placeholder,
suffix,
className,
...props
}) => {
return (
<div className={styles.inputWrapper}>
<input
className={clsx(styles.input, className)}
value={value}
placeholder={placeholder}
{...props}
/>
<div className={styles.inputFakeValueWrapper}>
<span className={styles.inputFakeValue}>{value || placeholder}</span>
<span className={styles.suffix}>{suffix}</span>
</div>
</div>
)
}
Давайте в каком нибудь компоненте используем наш input и попробуем ввести туда что-нибудь
Скрины что у нас получилось
Как видим у нас не хватает отступа от краев от самого инпута и отступа между суффиксом и текстом. А так же самая большая проблема -- когда текст подходит к краю ввода, то он накладывается на наш суффикс.
Собственно говоря решение тут самое простое -- это вычислять padding для input с правой стороны, величина этого padding должна вычисляться по формуле:
padding = ширина suffix + padding инпута с левой стороны + отступ между текстом и суффиксом
Так же желательно чтобы все это вычислилось до того как наш экран перерисуется реактом, чтобы наш инпут не дергался уже после того, как мы что то показали пользователю. Для этого мы можем воспользоваться хуком useLayoutEffect
Так же мы используем useRef для того чтобы получить доступ к нашему суффиксу и узнать его длину
const suffixRef = useRef<HTMLSpanElement>(null)
const [inputRightPadding, setInputRightPadding] = useState<number>(0)
useLayoutEffect(() => {
const suffixWidth = suffixRef.current?.offsetWidth
setInputRightPadding(
suffix && suffixWidth
? suffixWidth + (inputPadding + suffixGap)
: inputPadding,
)
}, [suffix])
C помощью const suffixWidth = suffixRef.current?.offsetWidth
узнаем ширину элемента суффикса
Если в пропах мы передали suffix -- то тогда вычисляем паддинг по формуле, если мы его не передали, то устанавливаем паддинг стандартный (в нашем случае паддинг равный паддингу с левой стороны)
В inputRightPadding сохраняем наше вычисленное значение
Не забывам повесить suffixRef на наш суфикс
<span ref={suffixRef} className={styles.suffix}>
{suffix}
</span>
А так же указать у useLayoutEffect в deps наш проп с помощью которого передаем наш суффикс [suffix]
, если он изменится наш паддинг с правой стороны перерасчитается
Пусть inputPadding
и suffixGap
будут константами указанными где то вне нашего компонента для простоты и так же не забудем добавить через style наш стандартный паддинг к самому input и inputFakeValueWrapper
Переопределяем правый паддинг у input paddingRight: inputRightPadding
У inputFakeValueWrapper в style так же укажем отступ между inputFakeValue и suffix с помощью gap
Наш конечный код получился такой
Input.tsx
import {
FC,
InputHTMLAttributes,
ReactNode,
useLayoutEffect,
useRef,
useState,
} from 'react'
import clsx from 'clsx'
import styles from './Input.module.scss'
export type InputProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
'style'
> & {
suffix?: ReactNode
}
const inputPadding = 20 as const
const suffixGap = 10 as const
export const Input: FC<InputProps> = ({
value,
placeholder,
suffix,
className,
...props
}) => {
const suffixRef = useRef<HTMLSpanElement>(null)
const [inputRightPadding, setInputRightPadding] = useState<number>(0)
useLayoutEffect(() => {
const suffixWidth = suffixRef.current?.offsetWidth
setInputRightPadding(
suffix && suffixWidth
? suffixWidth + (inputPadding + suffixGap)
: inputPadding,
)
}, [suffix])
return (
<div className={styles.inputWrapper}>
<input
className={clsx(styles.input, className)}
style={{
padding: inputPadding,
paddingRight: inputRightPadding,
}}
value={value}
placeholder={placeholder}
{...props}
/>
<div
className={styles.inputFakeValueWrapper}
style={{ gap: suffixGap, padding: inputPadding }}
>
<span className={styles.inputFakeValue}>{value || placeholder}</span>
<span ref={suffixRef} className={styles.suffix}>
{suffix}
</span>
</div>
</div>
)
}
Input.module.scss
.inputWrapper {
display: flex;
width: 400px;
height: 80px;
position: relative;
font-size: 30px;
line-height: 32px;
font-family: sans-serif;
}
.input {
font-size: inherit;
line-height: inherit;
font-family: inherit;
width: 100%;
background-color: #FFFFFF;
outline: none;
border: 1px solid #007BFF;
border-radius: 10px;
}
.inputFakeValueWrapper {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
display: flex;
align-items: center;
visibility: hidden;
user-select: none;
pointer-events: none;
font-size: inherit;
line-height: inherit;
font-family: inherit;
}
.inputFakeValue {
overflow: hidden;
}
.suffix {
visibility: visible;
height: 100%;
display: flex;
align-items: center;
}
Запускаем проверяем