События мыши
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Мышь — самый разговорчивый ввод в браузере: одно короткое нажатие превращается в очередь из нескольких событий, и половина ошибок в интерфейсах живёт именно в разнице между click и его соседями. Разберёмся, кто за кем идёт, чем mouseenter отличается от mouseover и в какой системе координат браузер сообщает, куда именно ткнули.
Щелчок мышью — не одно событие, а маленькая пьеса в трёх действиях. Кнопка пошла вниз — mousedown. Кнопка вернулась — mouseup. И только если оба действия случились на одном элементе, браузер добавляет третье, итоговое — click.
Метод click() — вежливая подделка: он посылает само событие click и ничего больше. Это удобно в тестах и это же ловушка, если полезная логика висит на mousedown. Двойной щелчок добавляет ещё одно событие поверх двух обычных кликов: сначала два click, и только потом dblclick.
Свойство detail — счётчик кликов подряд. У настоящего первого клика там единица, у второго двойка, у тройного щелчка — тройка. Поэтому «сделать что-то только по одиночному клику» обычно решается проверкой event.detail === 1, а не таймером.
Половинки клика нужны там, где важен сам процесс, а не факт: перетаскивание, рисование, ползунок громкости, кнопка «держать для записи». Здесь mousedown открывает состояние, mouseup его закрывает.
У этого кода есть известная беда: пользователь зажал кнопку на элементе, увёл курсор в сторону и отпустил там. mouseup придёт другому элементу, а наш останется навсегда «зажатым». Поэтому в реальном перетаскивании mousedown вешают на элемент, а mousemove и mouseup — на document, и снимают их, когда всё закончилось.
Тогда click не пропадает — он выстреливает на ближайшем общем предке двух элементов. Нажали на одной ячейке таблицы, отпустили на соседней — клик получит строка или сама таблица, но не ячейка. Проверять event.target в таких обработчиках стоит всегда.
Ещё одна деталь: mousedown — то самое событие, у которого действие по умолчанию начинает выделение текста и переносит фокус. Вызов event.preventDefault() внутри mousedown отменяет и то и другое. Это стандартный приём для выпадающих списков: клик по пункту не должен уводить фокус из поля ввода.
Обе пары говорят «курсор пришёл» и «курсор ушёл», но ведут себя по-разному. mouseover и mouseout срабатывают на каждом элементе под курсором и всплывают вверх по дереву. mouseenter и mouseleave не всплывают вовсе и не замечают путешествий курсора внутри элемента.
| Событие | Всплывает | Реагирует на переход к потомку |
|---|---|---|
| mouseover | да | да — родитель получает его снова |
| mouseout | да | да — выглядит как «курсор ушёл» |
| mouseenter | нет | нет |
| mouseleave | нет | нет |
Отсюда правило: для подсветки элемента целиком берите mouseenter и mouseleave — меньше кода и никакого мигания. mouseover оставьте для делегирования, когда один обработчик на контейнере обслуживает сотню строк списка.
У событий этой четвёрки есть свойство relatedTarget — второй участник перехода. Для mouseout это элемент, куда курсор направляется, для mouseover — откуда пришёл. Так самодельное меню понимает, что курсор ушёл к своему же подпункту и закрываться не надо.
Если курсор ушёл за пределы окна, relatedTarget будет null. Для contains это безопасно: contains(null) вернёт false, и меню закроется — как раз то поведение, которое нужно. Проверка нужна там, где вы обращаетесь к самому узлу: event.relatedTarget.closest("#menu") на null бросит TypeError.
Какая кнопка сработала, рассказывает event.button: 0 — левая (точнее, основная), 1 — колесо, 2 — правая. Свойство осмысленно у событий, где кнопка меняет состояние, — mousedown, mouseup, click, auxclick, contextmenu; у mousemove смотрят на event.buttons — битовую маску всех зажатых сейчас кнопок, где младший бит означает левую.
Главная неожиданность: click достаётся только основной кнопке. Любая неосновная — и средняя, и правая — вместо него порождает auxclick, а правая вдобавок присылает contextmenu. Поэтому «повесил обработчик на click и проверяю button === 2» не работает никогда, а auxclick — не «событие колеса»: чтобы поймать именно нажатие колеса, проверяйте внутри него event.button === 1.
Элемент не отдаёт клики? Прежде чем чинить код, проверьте, не лежит ли поверх прозрачный слой: событие всегда получает самый верхний элемент в точке курсора, а не тот, который видно.
Браузер сообщает точку клика сразу в нескольких системах отсчёта. clientX и clientY — от левого верхнего угла видимой области, они не зависят от прокрутки. pageX и pageY — от начала документа, то есть clientX плюс прокрутка. Есть ещё screenX и screenY (от угла монитора) и offsetX с offsetY — от угла самого элемента.
Практическое правило: с position: fixed и с getBoundingClientRect() дружит clientX — они в одной системе координат. pageX нужен там, где точку запоминают надолго: подпись к месту на длинной странице переживёт прокрутку только в координатах документа. Чтобы понять, куда попал клик внутри элемента, вычитаем из clientY границу из getBoundingClientRect().
Полная очередь одного настоящего клика по ссылке выглядит так: mouseover и mouseenter, когда курсор пришёл на элемент, затем mousemove, потом mousedown, перевод фокуса, mouseup и наконец click. Если щелчок был вторым за короткое время, следом придёт dblclick. Действие по умолчанию — переход по ссылке, отправка формы — случается уже после click, и его отменяет preventDefault().
Пара target и currentTarget знакома с статьи об объекте события, и у мыши она читается буквально: target — самый глубокий элемент под курсором, тот, по кому реально попали, currentTarget — элемент, на котором висит текущий обработчик. На этой паре стоит делегирование: один обработчик на контейнере плюс event.target.closest("li") заменяют сотню подписок и продолжают работать, когда список перерисовали.
- Щелчок — это очередь: mousedown, mouseup и только потом click; программный click() посылает лишь последнее из трёх.
- mouseover и mouseout всплывают и срабатывают на потомках, mouseenter и mouseleave — нет; для подсветки элемента целиком берите вторую пару.
- click достаётся только основной кнопке: любая неосновная даёт auxclick, а правая вдобавок contextmenu; какая именно нажата, различает event.button числами 0, 1 и 2.
- clientX считается от видимой области, pageX — от начала документа, и отличаются они ровно на величину прокрутки.
- event.target — куда попали, event.currentTarget — где висит обработчик; на этой паре держится делегирование.
1/3На кнопке висят обработчики mousedown, mouseup и click. Что попадёт в лог после вызова btn.click() из кода?
2/3На родительском блоке есть обработчик. Курсор был на самом блоке и переехал на вложенный span. Что произойдёт?
3/3Страницу прокрутили на 200 пикселей вниз и кликнули в ту же точку экрана, что и до прокрутки. Как изменятся координаты события?
Клик по пункту списка, а не по его начинке
Один обработчик на списке должен печатать data-id пункта, по которому кликнули. Но кликают обычно по вложенному тексту, и event.target оказывается совсем не тем элементом. Почините код так, чтобы в лог попало a.
Клик пришёл от <b>: event.target — это всегда самый глубокий элемент под курсором, а не тот, кому мы мысленно адресовали клик. Поднимаемся от него вверх методом closest.
Проверка menu.contains(item) страхует от случая, когда обработчик висит выше по дереву: closest тогда способен дотянуться до чужого <li>. А проверка на null нужна для кликов по пустому месту контейнера.
Назвать нажатую кнопку и убрать системное меню
Сейчас код печатает голые числа. Сделайте так, чтобы в лог попадали слова левая и правая, а системное контекстное меню по правой кнопке не появлялось.
Числа кнопок стоит превратить в имена через справочник, а не через цепочку if. Системное меню отменяется не в mousedown, а в отдельном событии contextmenu — это разные события, и preventDefault() в первом на второе не влияет.
Запасное значение неизвестная не лишнее: у мышей с боковыми кнопками event.button бывает 3 и 4. И помните, что отменять контекстное меню стоит только там, где вы даёте пользователю собственное — иначе вы просто отбираете у него привычный инструмент.