Большинство багов в интерфейсе косметические. Но пара из них меняет числа, которые пользователь менять не собирался, и если такими числами оказываются кредитное плечо, размер ордера или цена, баг уже перестаёт быть косметическим.
Вот две проблемы, с которыми мы столкнулись. Обе вызваны поведением браузера и библиотек, которое формально задокументировано, но на практике незаметно. Исправления оказались маленькими. Найти их было непросто.
Главное
- Числовое поле <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 и визуальное дерево перестают совпадать, и любой код, который их отождествлял, теперь ошибается.
