Pull to refresh

React: как сделать динамический суффикс в <input />, который будет двигаться вместе с набранным текстом

Level of difficultyEasy
Reading time6 min
Views7.5K

Задача

Необходимо сделать input с помощью React, в котором, после текста отображается какое то значение. Будем называть это значение суффиксом.

Условия

  1. Cуффикс не должен подмешиваться к самому значению инпута, т.e. чтобы мы на каждый change эвент не брали строку и не отделяли этот суффикс, а потом все снова складывали

  2. Суффикс во время ввода должен всегда быть виден

  3. Суффикс может быть другим react элементом (например картинкой, или текстом)

  4. Если мы передадим во время работы приложения новое значение пропа суффикса -- он должен нормально перерендериться, инпут не должен сломаться

  5. Суффикс нельзя выделить, скопировать, как либо с ним провзаимодействовать. Он не должен перекрывать поле инпута

  6. Поведение инпута никак не должно отличаться от обычного

Какой результат мы получим в конце статьи

Пример на codesandbox, который можно потыкать

Что я буду использовать в проекте

  1. React

  2. Typescript

  3. SCSS - для удобства описания стилей

  4. 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;
}

Запускаем проверяем

Cсылки на codesandbox и github

codesandbox
github

Only registered users can participate in poll. Log in, please.
Была ли полезна эта статья?
32.56% Да14
46.51% Да, спасибо20
20.93% Нет9
43 users voted. 9 users abstained.
Tags:
Hubs:
Total votes 5: ↑5 and ↓0+5
Comments10

Articles