Перейти к основному содержимому
engineeringreactjavascript
Колёсико мыши тихо меняет значения в ваших числовых полях

Колёсико мыши тихо меняет значения в ваших числовых полях

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

Вот две проблемы, с которыми мы столкнулись. Обе вызваны поведением браузера и библиотек, которое формально задокументировано, но на практике незаметно. Исправления оказались маленькими. Найти их было непросто.

Главное

  • Числовое поле <input type="number"> в фокусе увеличивает или уменьшает значение, когда вы прокручиваете колёсико над ним. На поле с деньгами это молча переписывает значение.
  • Исправляйте это через blur(), а не preventDefault(): снятие фокуса отменяет изменение, при этом прокрутка страницы и модального окна остаётся полностью нетронутой.
  • Radix popover, вынесенный через портал в document.body, не может удержать фокус внутри диалога Radix, потому что область фокуса диалога вытягивает фокус обратно наружу. Поле выглядит активным, но съедает каждое нажатие клавиши.
  • Передайте сам элемент диалога как контейнер портала для popover, чтобы он стал частью захваченного поддерева.
  • Это исправление ломает самописные обработчики клика снаружи: как только контент вынесен в портал, он больше не является DOM-потомком той обёртки, относительно которой вы делали замеры.

1. Колёсико это жест редактирования, а пользователю об этом никто не сказал

У нативных числовых полей есть поведение, восходящее к спецификации: когда поле в фокусе, прокрутка колёсика мыши над ним увеличивает или уменьшает значение на величину атрибута step.

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

Наивное исправление: навесить обработчик wheel на каждый input и вызвать preventDefault(). Это работает, но заодно ломает прокрутку: пользователь больше не может прокручивать страницу, пока курсор случайно оказался над числовым полем, и это ощущается сломанным уже совсем по-другому.

Более удачное исправление опирается на условие из спецификации. Изменение происходит только когда поле в фокусе, поэтому достаточно снять фокус:

const onWheel = (e) => {
  const active = document.activeElement;
  if (
    active instanceof HTMLInputElement &&
    active.type === "number" &&
    (active === e.target || active.contains(e.target))
  ) {
    active.blur();   // kills the increment, leaves scrolling intact
  }
};
document.addEventListener("wheel", onWheel, { passive: true, capture: true });

Три осознанных выбора в этом фрагменте:

  • blur(), а не preventDefault(). Мы не отменяем прокрутку, мы убираем условие, которое превращает её в редактирование. Страница продолжает прокручиваться как обычно.
  • capture: true. Слушатель наблюдает событие на пути вниз, поэтому он всё равно срабатывает, даже если какой-то внутренний компонент останавливает распространение.
  • passive: true. Безопасно именно потому, что мы никогда не вызываем preventDefault(), и это держит слушатель вне критического пути прокрутки.

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

2. Popover внутри диалога, в который невозможно ничего напечатать

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

Причина в столкновении двух правильных поведений.

Popover-ы Radix по умолчанию выносят свой контент через портал в document.body, и на обычной странице это правильно, потому что так контент избегает переполнения и контекстов наложения. Диалоги Radix захватывают фокус внутри собственного поддерева, что тоже правильно, и именно это делает модальное окно модальным.

Соедините их вместе, и контент popover, с точки зрения DOM, оказывается снаружи диалога. Область фокуса диалога проверяет каждое входящее событие фокуса примерно так:

container.contains(target) ? allow : focus(lastFocusedElement)

Поле поиска не содержится внутри диалога, поэтому каждое событие фокуса, попадавшее на него, тут же отменялось. Поле отрисовывалось, на мгновение показывало каретку и теряло фокус ещё до того, как успевало зарегистрироваться нажатие клавиши. Оно выглядело живым и проглатывало всё.

Исправление состоит в том, чтобы перестать врать о том, где живёт popover. Портал Radix принимает container, поэтому мы отрисовываем пустой div нулевой высоты внутри диалога и выносим popover через портал в него:

// inside the dialog
<div ref={setDropdownHost} />
...
<PopoverContent container={dropdownHost}>…</PopoverContent>

Две детали, которые легко сделать неправильно:

  • Состояние, а не ref. Узел-хост не существует при первом рендере, и порталу нужен повторный рендер, как только он появится. useRef удержал бы узел, но никогда не запустил бы этот рендер.
  • Хост не должен влиять на разметку. Он пустой и нулевой высоты, поэтому может находиться в любом удобном месте разметки диалога, ничего не сдвигая.

3. Исправление, которое тихо ломает код по соседству

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

До перехода на popover с порталом у выпадающего списка был самописный обработчик клика снаружи: держим ref на обёртке, слушаем клики по документу, закрываем, если клик был не внутри.

Как только контент вынесен в портал, он больше не является DOM-потомком этой обёртки, хотя визуально находится внутри неё. Поэтому обработчик теперь видит клики по собственному полю поиска выпадающего списка как клики снаружи и закрывает именно то, чем пользователь пытается пользоваться.

Удаление этого обработчика было не уборкой, а частью исправления. Общее правило:

Вынос в портал ломает любое предположение, основанное на вложенности в DOM. Обработчики клика снаружи, поиск через closest(), делегирование событий и CSS-селекторы потомков: все они тихо меняют смысл. Когда вы выносите что-то в портал, идите и найдите код, который рассчитывал на вложенность.

Что общего у этих двух багов

Оба сидят в зазоре между «библиотека ведёт себя правильно» и «пользователю плохо». Ничто не бросило исключение. Ничто не попало в лог. В обоих случаях компонент на скриншоте выглядел правильно.

Общие уроки, которые я оставил бы себе:

  • Знайте, какие жесты платформа трактует как редактирование. Колёсико над числовым полем в фокусе один из них; таким же жестом является вставка по средней кнопке мыши в Linux. Если в поле лежат деньги, проверьте их.
  • Предпочитайте убрать предусловие, а не отменить событие. blur() победил preventDefault(), потому что он целил в причину, а не подавлял симптом, и оставил не связанное с ним поведение нетронутым.
  • Решайте один раз и глобально, когда режим отказа звучит как «кто-то забудет». У исправления на уровне отдельного поля есть срок годности.
  • Когда выносите в портал, проверяйте предположения о вложенности. Дерево DOM и визуальное дерево перестают совпадать, и любой код, который их отождествлял, теперь ошибается.

Читайте также

Создайте аккаунт

Примените знания на практике: откройте аккаунт Freya уже сегодня.

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