Привет, Хабр! На связи команда фронтенд-разработки из ecom.tech. Меня зовут Миша, я занимаюсь разработкой интерфейсов для внутренних сервисов. Например, мы сделали удобное приложение для курьеров-партнёров Самоката, сервис для быстрой работы логистов, интуитивно понятный терминал для складов. 

В этой статье я расскажу, как мы переизобрели взаимодействия с инпутами и написали свою клавиатуру для мобильного приложения – вас ждёт код и пошаговое описание. Поехали!

Внутренние сервисы

Мы хотели разработать приложение, которым будут пользоваться через ТСД на складах, чтобы ускорить время приемки и отправки грузов. 

ТСД - Терминал Сбора Данных
ТСД - Терминал Сбора Данных

Приложение нужно было сделать удобным для конечного пользователя – кладовщика. Что мы знаем о нём?

  • Работает в грубых перчатках, с небольшим ТСД – а значит, клавиатура должна иметь большие кнопки.  

  • Хочет иметь быстрый доступ ко всем кнопкам на одном экране без дополнительных модалок – а значит, клавиатура должна быть простой и интуитивно понятной. 

  • Не самый большой поклонник пользовательских сценариев, где нужно свайпать :)

Пишем свою клавиатуру

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

Далее я приведу несколько требований, применяемых для такого компонента (в вашем случае требования могут отличаться):

  • Управление отображением клавиатуры (иногда она не нужна).

  • Управление состоянием клавиш. Нам нужны только цифры и функциональные кнопки.

  • Некоторые визуальные улучшения. Эффект нажатия на кнопку, достаточная ширина кнопок, управление цветом, иконки.

Ниже — пример шаблона, с которым мы начали: 

<template>
  <div v-show="isShowVirtualKeyboard" class="keyboard" :class="{ collapsed: isCollapsed }">
    
    <button @click="onKeyPress('1')" class="key">1</button>
    <!-- цифры от 1 до 5 ... -->
    <button @click="onKeyPress('5')" class="key">0</button>
    
    <div class="divider" />
    <button @click="onKeyPress('back')" class="key big-key">⌫</button>
    <div class="divider" />
    
    <!-- Функциональная кнопка -->
    <button
      :disabled="!buttons['f1'].active"
      :style="`background: ${buttons['f1'].color}`"
      @click="buttons['f1'].onClick"
      class="key"
      data-test="f1-button"
    >
      {{ buttons['f1'].title }}
    </button>
    
    <!-- Далее продолжить второй ряд для цифр от 6 до 0 и т.д. -->
    
  </div>
</template>

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

interface ButtonOptions {
  title?: string | ComputedRef<string>
  color?: string | ComputedRef<string>
  active?: boolean | ComputedRef<boolean>
  onClick?: () => void
}

А вот и то, что возвращает сам стор:

const virtualKeyboardStore = () => {

  /**
  * ...
  * Скучный код стора
  * ...
  */
  
  return {
    buttons, // функциональные кнопки

    setButton, // управление функциональной кнопки
    resetButton, // сбрасывает состояние (кнопка серая и неактивная)
    setActiveButton, // убирает у кнопки disabled
    setInactiveButton // устанавливает кнопку в состояние disabled
  }
}

Незамысловатые стили решают задачу визуальных улучшений, а управление отображением клавиатуры устанавливается также в роутере:

meta: { isShowVirtualKeyboard: true }

Проектируем кастомный input

Виртуальная клавиатура готова. 

Она будет взаимодействовать с инпутами, куда попадают все введенные данные. На мобильных устройствах инпут ведет себя привычно – ловит фокус, отображает его, вызывает нативную клавиатуру, слушает события. Требования, с учетом существования виртуальной клавиатуры, кажется, простые:

  • При фокусе не показывать нативную клавиатуру;

  • слушать события виртуальной клавиатуры;

  • поддерживать маски;

  • поддерживать валидации;

  • вводить дефолтные значения.

<template>
  <label @click="setActive()" class="base-label" :class="{ 'base-label_invalid': hasError }">
    <span class="base-label__placeholder">{{ placeholder }}</span>
    <input
      data-test="base-input"
      v-model="model"
      type="text"
      ref="input"
      @focus="onFocus"
      @paste="onPaste"
      @blur="onBlur"
      @keypress="$emit('keypress', $event)"
      class="base-input"
      :name="name"
      :disabled="disabled"
      autocomplete="off"
    />
  </label>
</template>

Первым делом слушаем события из виртуальной клавиатуры и редактируем модель.

const onCustomInput = (e: { detail: string }) => {
    if (e.detail === 'back') {
      model.value = model.value.slice(0, -1)
    }
    if (!isNaN(e.detail)) {
      model.value += e.detail
    }
}

Несложно, да? Но чтобы намазать варенье на хлеб, нужно сначала открыть банку. Чтобы модель начала заполняться, нужно сначала кликнуть/тапнуть на элемент... После чего ��ы увидим нативную мобильную клавиатуру.

Используем хак onFocus:

const onFocus = () => {

  // Введем поддержку virtual keyboard через дополнительный prop
  if (props.isVirtualKeyboard) {
    input.value?.setAttribute('inputmode', 'none') // хак для скрывания нативной клавиатуры
    input.value?.focus()
  }

  // Будем использовать дополнительный метод для "активации" элемента,
  // на котором фокусируется пользователь
  setActive()

}

Чтобы событие не прослушивалось во всех инпутах сразу, введем новое состояние активности текущего инпута. setActive() – управляет активностью элемента ввода. Пока isActive === true, событие из виртуальной клавиатуры слушаем, а также держим нативный focus на элементе. 

const setActive = async (isActive: boolean = true) => {

  if (!isActive) {
    input.value?.blur()
    active.value = false
  } else {
    active.value = true
    input.value?.focus()
  }

}

watch(active, (isActive) => {
  if (isActive) {
    document.addEventListener('customInput', onCustomInput)
  } else {
    document.removeEventListener('customInput', onCustomInput)
  }
})

onUnmounted(() => {
  document.removeEventListener('customInput', onCustomInput)
})

// Вдруг мы захотим управлять этим извне (спойлер: захотим)
defineExpose({
  setActive
})

При смене фокуса нужно внимательно следить за активным элементом:

const onBlur = () => {

  setTimeout(() => {
    if (document.activeElement?.getAttribute('name') !== props.name) {
      setActive(false)
    }
  }, 100)

}

setTimeout встретится еще много раз. Здесь он позволяет оставлять поле активным в тот момент, когда когда пользовательский фокус (:focus) сместится на кнопку виртуальной клавиатуры. Привязка к атрибуту решает проблему фокусирования на разных элементах, например в форме с несколькими полями ввода. Если юзер меняет фокус на другой инпут или тапает вне элемента, то должен сработать blur и элемент станет неактивным.

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

const masked = IMask.createMask(props.mask ? { ...props.mask } : { mask: /\W*/ })

// а теперь обновленный onCustomInput

const onCustomInput = (e: { detail: string }) => {
  if (active.value) {
    if (e.detail === 'back') {
      masked.resolve(model.value.slice(0, -1))
      model.value = masked.value
    }
    // принимает новые значения только если маска не завершена
    if (!isNaN(+e.detail)) {
      masked.resolve(model.value + e.detail)
      model.value = masked.value
    }
    input.value?.focus()
  }
}

watch(model, (newVal) => {
  
  masked.resolve(newVal)
  model.value = masked.value
  input.value?.focus() // чтобы не слетал фокус на устройстве ТСД
  emit('change', model.value)

})

Также здесь присутствует обработка onPaste. Резонный вопрос: зачем она нужна, если вводом в инпут управляет пользователь с виртуальной клавиатуры? Дело в том, что разные модели ТСД могут иметь разные настройки управлением ввода отсканированного значения. Какие-то эмулируют нажатие клавиш с задержкой в N мс. Какие-то эмулируют вставку значения. 

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

const onPaste = (e: any) => {

  const pastedData = e.clipboardData.getData('Text')
  if (pastedData) {
    model.value = '' // очищает поле ввода при вставке значения из буфера
    setTimeout(() => {
      model.value = pastedData // устанавливает нужное значение в следующем тике
    }, 0)
    input.value?.focus()
  }

}

Важно рядом с BaseInput слушать событие Enter. Например, был случай, когда ввод qr-кода в 150+ символов эмулировал нажатие клавиш с задержкой в 4 мс (150 * 4 = 600 мс) и был отсканирован дважды, что создавало лаг в 1.2 секунды и получение ошибки от сервера о невалидном введенном токене.

<!-- QRScan.vue -->

<template>
  <div v-if="!loading" class="qr-scan">
    <BaseInput
      ref="qr"
      name="qr"
      v-model="qrCode"
      placeholder="Сканируйте код реестра"
      @keypress="onChange"
      is-active
      is-virtual-keyboard
    />
  </div>
  <div class="qr-loading" v-else>Идет загрузка...</div>
</template>
// QRScan.vue - script

<script setup lang="ts">
  // ...
  
  const onChange = async (e: any) => {
    if (e.key === 'Enter') {
      // ... логика отправки QR кода ...
    }
  }
  
  // ...
</script>
Полный код компонента BaseInput.vue
<script setup lang="ts">
import { IMask } from 'vue-imask'
import type { Ref } from 'vue'
import { ref, watch, onUnmounted, onMounted } from 'vue'

interface IBaseInputProps {
  name: string
  mask?: any
  isVirtualKeyboard?: boolean
  placeholder?: string
  hasError?: boolean
  isActive?: boolean
  disabled?: boolean
}

const model = defineModel() as Ref<string>
const props = withDefaults(defineProps<IBaseInputProps>(), {
  hasError: false
})
const emit = defineEmits(['change', 'keypress'])

const masked = IMask.createMask(props.mask ? { ...props.mask } : { mask: /\W*/ })

const input = ref<HTMLInputElement | null>()
const active = ref<boolean>(false)

const setActive = async (isActive: boolean = true) => {
  if (!isActive) {
    input.value?.blur()
    active.value = false
  } else {
    active.value = true
    input.value?.focus()
  }
}

const onPaste = (e: any) => {
  const pastedData = e.clipboardData.getData('Text')
  if (pastedData) {
    model.value = '' // очищает поле ввода при вставке значения из буфера
    setTimeout(() => {
      model.value = pastedData // устанавливает нужное значение в следующем тике
    }, 0)
    input.value?.focus()
  }
}

const onCustomInput = (e: { detail: string }) => {
  if (active.value) {
    if (e.detail === 'back') {
      masked.resolve(model.value.slice(0, -1))
      model.value = masked.value
    }
    // принимает новые значения только если маска не завершена
    if (!isNaN(+e.detail)) {
      masked.resolve(model.value + e.detail)
      model.value = masked.value
    }
    input.value?.focus()
  }
}

const onFocus = () => {
  if (props.isVirtualKeyboard) {
    input.value?.setAttribute('inputmode', 'none') // хак для скрывания нативной клавиатуры
    input.value?.focus()
  }
  setActive()
}

const onBlur = () => {
  setTimeout(() => {
    if (document.activeElement?.getAttribute('name') !== props.name) {
      setActive(false)
    }
  }, 100)
}

watch(model, (newVal) => {
  masked.resolve(newVal)
  model.value = masked.value

  input.value?.focus() // чтобы не слетал фокус на ТСД
  emit('change', model.value)
})

watch(active, (isActive) => {
  if (isActive) {
    document.addEventListener('customInput', onCustomInput)
  } else {
    document.removeEventListener('customInput', onCustomInput)
  }
})

watch(
  () => props.isActive,
  (isActive) => {
    if (isActive) {
      setActive()
    } else {
      setActive(false)
    }
  }
)

onMounted(async () => {
  if (props.isActive) {
    // после tick устанавливает фокус, нужно из-за задержки на onBlur()
    // nextTick не подойдет
    setTimeout(() => {
      onFocus()
    }, 0)
  }
})

onUnmounted(() => {
  document.removeEventListener('customInput', onCustomInput)
})

defineExpose({
  setActive
})
</script>

В чём польза собственной клавиатуры?

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

В нашем примере эффект от внедрения — UX-аналитика показала увеличение скорость приёмки и обработки грузов в 3.5 раза. Такая оптимизация особенно важна на большом масштабе, когда счёт идё�� на сотни складов, тысячи кладовщиков, миллионы товаров. 

Кроме того, виртуальные клавиатуры могут помочь уменьшить ошибки при вводе данных, что также является важной задачей для обеспечения точности и надежности операций. 

Разработка виртуальных клавиатур и компонентов ввода – это шаг вперед к созданию более современных и эффективных складских операций. В каком бы домене вы ни работали, если у вас есть возможность оптимизировать ручной труд через создание более удобных интерфейсов – не сдерживайте себя!