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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Задача

Необходимо сделать 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

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Была ли полезна эта статья?
0% Да 0
66.67% Да, спасибо 2
33.33% Нет 1
Проголосовали 3 пользователя. Воздержался 1 пользователь.
Источник: https://habr.com/ru/articles/743768/


Интересные статьи

Интересные статьи

Илон Маск вливает деньги в разработку корабля, способного доставлять людей на Красную планету. Starship должен стать первой многоразовой транспортной системой, способной доставлять на Марс до 100 чело...
Эта история началась, когда «деревья ещё были маленькими, рожь колосилась, а я ходил пешком под стол». Хотя нет, вру, это было гораздо позднее, а конкретно, эта история приключилась в начале 2010-х ...
Люди, которые застали динозавров и пейджеры, могут помнить, что когда-то давно, диктуя сообщение девушке-оператору пейджинговой компании, можно было услышать в ответ «Это сообщение оскорбительно для...
Ура! Мы завершили формирование программы конференции UseData Conf 2019! Эта конференция для тех, кто решает практические задачи с помощью методов машинного обучения. Между идеальным алгоритмом в ...
Я начал погружение в мир IT лишь три недели назад. Серьезно, три недели назад я даже не понимал синтаксиса HTML, а знакомство с языками программирования заканчивалось школьной программой по Pasca...