
Привет, Хабр! На связи команда фронтенд-разработки из 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 раза. Такая оптимизация особенно важна на большом масштабе, когда счёт идё�� на сотни складов, тысячи кладовщиков, миллионы товаров.
Кроме того, виртуальные клавиатуры могут помочь уменьшить ошибки при вводе данных, что также является важной задачей для обеспечения точности и надежности операций.
Разработка виртуальных клавиатур и компонентов ввода – это шаг вперед к созданию более современных и эффективных складских операций. В каком бы домене вы ни работали, если у вас есть возможность оптимизировать ручной труд через создание более удобных интерфейсов – не сдерживайте себя!
