Задача

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

Комментарии (8)


  1. Kuch
    25.06.2023 17:46
    +1

    Ещё было бы хорошо добавить возможность располагать суффикс перед значением (префикс). Таким образом можно было бы сделать инаут с лейблом внутри.

    То есть префикс например Name: и находится внутри инпута


  1. nronnie
    25.06.2023 17:46
    +6

    Я вообще далек от разработки фронтенда. Но у меня, как у пользователя, от такого поведения текстбокса мозг расплавило бы. Открываешь полдюжины разных сайтов и на каждом самые безобидные с виду элементы управления ведут себя как-то по своему. Фронтендеры, оставьте, наконец-то, контролы в покое - пускай текстбоксы ведет себя просто как текстбоксы, а кнопки просто как кнопки :)) Я полгода не мог привыкнуть к вводу телефона в мобильном банке Сбера, который при вводе первой цифры автоматически вставлял впереди неё "+7", при этом на то что префикс перед номером в этом поле вводить самому не надо ничего не указывало - при инициализации с виду это было обычное, пустое текстовое поле.


    1. shark14
      25.06.2023 17:46
      +2

      Так это же во многом маркетинг — пытаются "впечатлить" пользователя, сделать не так, как у всех. При обычно эти все впечатлятели искренне уверены в том, что "именно так, как у нас, правильно, а остальные просто еще не доросли".


    1. Fines_Unes Автор
      25.06.2023 17:46
      +2

      Я в каком то смысле вообще тоже скучаю по старым неконтролируемым формам, но дизайнеры диктуют тренды. Как например с логином, теперь уже многих дизайнеров не устраивает просто форма на одной странице, нужно обязательно разбить ее на 10 шагов и чтобы было все на разных страницах (и по одному полю на этой странице). Почему теперь не модно просто дать пользователю ввести этот же телефон как он желает, используя у инпутаtype="tel", а мы как программисты сами бы уже обработали и преобразовали его в удобный нам формат? -- нет, нужно обязательно чтобы были свистелки и фонарики (и ведь эти свистелки и фонарики далеко не всегда помогают пользователю). Короче для меня это тоже глубочайшая загадка.


      1. nronnie
        25.06.2023 17:46

        У нас когда-то в продакшен ушел код с валидацией поля ввода фамилии. И все шло хорошо, пока через пару недель в поддержку не позвонил обескураженный человек с фамилией "Ю" (кореец :)))


      1. anarchomeritocrat
        25.06.2023 17:46

        )))

        А вообще, есть такое понятие "Функциональный дизайн", когда элементы оформления выполняют не только декоративную роль, но и реально расширяют пользовательский опыт. Но зачастую все рюшечки с побрякушками, помимо простого украшательства, ещё и усложняют восприятие, а при не очень удачной реализации программистом, ещё и норм так ресурсов могут поджирать.

        С другой стороны, это всё несёт сакральную роль таких себе экспериментов, благодаря которым собственно UX и развивается )


    1. k12th
      25.06.2023 17:46

      Фронтендеры тут не при чем, мы люди подневольные. Дизайнер нарисовал, бизнес утвердил, и отвоевать удается далеко не всегда.


  1. TataSysueva
    25.06.2023 17:46

    Хм, с такой же идеей можно сделать маску для ввода, как думаете? Те, что существуют в виде библиотек мне не подошли, хочу написать свою...