July 29th, 2021

JavaScript: подсказки к HTML-элементам

Решил задачу «Улучшенная подсказка» к подразделу 3.2 «Движение мыши: mouseover/out, mouseenter/leave» второй части учебника по JavaScript.

Как обычно, дана HTML-страница, код которой можно посмотреть в песочнице. На этой HTML-странице некоторые HTML-элементы содержат пользовательский атрибут data-tooltip (английское слово «tooltip» переводится на русский как «подсказка» или «всплывающая подсказка»; в скрипте я буду использовать английское слово «tip», одно из значений которого при переводе на русский тоже «подсказка»). В этом атрибуте может храниться либо простой текст, либо текст с HTML-тегами.

От нас требуется написать скрипт на языке JavaScript, который при наведении курсора мыши на HTML-элемент с атрибутом data-tooltip отобразит подсказку с текстом из этого атрибута. В большинстве случаев эта подсказка должна быть отображена над ее HTML-элементом.

Таким образом, автор HTML-страницы, вооружившись нашим скриптом, по задумке, сможет, добавляя в нужные ему HTML-элементы атрибут data-tooltip, придавать этим HTML-элементам способность отображать подсказку при наведении на них курсора мыши.

* * *

Похожую задачу «Поведение "подсказка"» я уже решал ранее к подразделу 2.3 «Делегирование событий» второй части обсуждаемого учебника. Я не писал пост с разбором той задачи, потому что не посчитал ее достаточно сложной, чтобы разбирать. Но ее решение — это база для решения задачи этого поста, поэтому придется сначала разобрать решение задачи «Поведение "подсказка"». Несмотря на то, что там для тестирования решения задана другая HTML-страница, само задание звучит почти так же, как и вышеизложенное задание в задаче этого поста. Единственное отличие состоит в том, что в задаче «Поведение "подсказка"» все HTML-элементы имеют родителем HTML-элемент body, а внутри них нет вложенных HTML-элементов, только простой текст. То есть все HTML-элементы находятся на одном уровне и являются соседями.

Чтобы охватить нашим скриптом все HTML-элементы в теле заданной HTML-страницы, используем делегирование событий: с помощью метода addEventListener повесим две функции (обработчики) на события mouseover (возникает при наведении курсора мыши на HTML-элемент) и mouseout (возникает при уходе курсора мыши с HTML-элемента) тела HTML-страницы. Тогда в этих функциях можно будет отловить указанные события на любом HTML-элементе, входящем в тело HTML-страницы. Пишем код:

document.addEventListener("mouseover", showTip);
document.addEventListener("mouseout", hideTip);

function showTip(event) {
    // ...тело функции...
}

function hideTip() {
    // ...тело функции...
}

Две функции (обработчики) я назвал showTip (показать подсказку) и hideTip (скрыть подсказку). В качестве первого параметра они получают объект event с информацией о произошедшем событии (слово «event» переводится на русский как «событие»).

По условиям задачи в каждый отдельный момент времени в браузере может быть показана только одна подсказка. Поэтому, думаю, логично будет создать глобальную переменную tip, которая будет содержать объект, представляющий подсказку. Тогда эта переменная будет доступна в обеих функциях-обработчиках, в которых нам нужно будет манипулировать подсказкой. Меняем код:

let tip = document.createElement("div"); // объект, представляющий подсказку
tip.className = "tooltip";               // ее CSS-класс
tip.hidden = true;                       // сначала подсказка скрыта
document.body.append(tip);               // добавим подсказку в тело HTML-страницы

document.addEventListener("mouseover", showTip);
document.addEventListener("mouseout", hideTip);

function showTip(event) {
    // ...тело функции...
}

function hideTip() {
    // ...тело функции...
}

CSS-класс tooltip уже описан на заданной HTML-странице. В этом CSS-классе для подсказки установлен тип позиционирования fixed (HTML-элемент фиксируется относительно левого верхнего угла области просмотра браузера). Нам остаётся вычислить и установить правильные координаты для подсказки, изменив CSS-свойства left и top для HTML-элемента, представляющего подсказку.

Включение видимости подсказки и установление координат подсказки будут производиться в функции showTip. В функции hideTip нам нужно лишь скрывать подсказку. Проще всего скрывать подсказку при событии mouseout на любом HTML-элементе тела заданной HTML-страницы. Даже если это совсем не тот HTML-элемент, к которому привязана подсказка, ничего страшного не произойдет: уже скрытая подсказка будет скрыта еще раз. Пользователь этого не заметит, а код функции hideTip будет максимально прост:
function hideTip() {
    tip.hidden = true;
}
С этой функцией закончили.

В функции showTip сначала определим, на какой HTML-элемент пользователь навел курсор. Если у этого HTML-элемента нет атрибута data-tooltip, завершим работу функции. В противном случае передадим в подсказку заданный в атрибуте data-tooltip текст и сделаем подсказку видимой:
function showTip(event) {
    let tar = event.target;
    if (!tar.dataset.tooltip) return;

    tip.innerHTML = tar.dataset.tooltip;
    tip.hidden = false;

    // ...вычисление координат подсказки и
    // перемещение HTML-элемента, представляющего подсказку...
}
Координаты подсказки будем вычислять после того, как сделаем ее видимой, потому что для невидимого HTML-элемента не получится определить его метрики (об этом было рассказано в подразделе 1.9 «Размеры и прокрутка элементов» второй части обсуждаемого учебника).

По условиям задачи в общем случае подсказка должна быть отображена над соответствующим HTML-элементом. С помощью метода getBoundingClientRect получим координаты целевого HTML-элемента относительно левого верхнего угла области просмотра браузера. Далее вычислим координаты подсказки так, чтобы она оказалась на 5px выше целевого HTML-элемента (таковы условия задачи) и переместим подсказку в нужное место. Меняем код функции showTip:
function showTip(event) {
    let tar = event.target;
    if (!tar.dataset.tooltip) return;

    tip.innerHTML = tar.dataset.tooltip;
    tip.hidden = false;

    let tarRect = tar.getBoundingClientRect(); // координаты HTML-элемента
    let x, y;                                  // координаты подсказки

    x = tarRect.x;                             // подсказка над
    y = tarRect.y - tip.offsetHeight - 5;      // HTML-элементом

    tip.style.left = x + "px";                 // перемещаем подсказку
    tip.style.top = y + "px";                  // в нужное место
}

На этом этапе задачу можно было бы считать выполненной, но при позиционировании подсказки в задаче требуется учесть два дополнительных момента: 1) подсказку следует размещать с горизонтальным выравниванием по центру целевого HTML-элемента (при этом подсказка не должна вылезти за левую границу области просмотра браузера); 2) если отображенная над целевым HTML-элементом подсказка вылезла за верхнюю границу области просмотра браузера, то ее следует отобразить под целевым HTML-элементом. Меняем код функции showTip:

function showTip(event) {
    let tar = event.target;
    if (!tar.dataset.tooltip) return;

    tip.innerHTML = tar.dataset.tooltip;
    tip.hidden = false;

    let tarRect = tar.getBoundingClientRect(); // координаты HTML-элемента
    let x, y;                                  // координаты подсказки
                                               // подсказка по центру HTML-элемента
    x = tarRect.x + tar.offsetWidth / 2 - tip.offsetWidth / 2;
    if (x < 0) x = 0;                          // корректируем, если вылезла слева
    
    y = tarRect.y - tip.offsetHeight - 5;      // подсказка над HTML-элементом
    if (y < 0) y = tarRect.y + tar.offsetHeight + 5; // или под ним

    tip.style.left = x + "px";                 // перемещаем подсказку
    tip.style.top = y + "px";                  // в нужное место
}

Отмечу, что вышеуказанная формула при горизонтальном выравнивании подсказки учитывает все три возможных случая: 1) ширина подсказки меньше ширины HTML-элемента, 2) ширина подсказки больше ширины HTML-элемента, 3) ширина подсказки равна ширине HTML-элемента.

Задача «Поведение "подсказка"» решена.

* * *

Вернемся к исходной задаче «Улучшенная подсказка». Заданная тестовая HTML-страница здесь посложнее: HTML-элементы с атрибутом data-tooltip присутствуют на разных уровнях DOM-дерева. Один из таких HTML-элементов может быть вложен в другой такой HTML-элемент, причем глубина вложенности не ограничена. Примерно вот так выглядит тело тестовой HTML-страницы:

<div data-tooltip="Здесь домашний интерьер" id="house">
  <div data-tooltip="Здесь крыша" id="roof"></div>

  <p>Жили-были на свете три поросенка.</p>

  <p>Даже имена у них были похожи.</p>

  <p>Но вот наступила осень. <a href="https://ru.wikipedia.org" data-tooltip="Читать далее...">Наведи курсор на меня</a></p>

</div>

В этом коде есть внешний HTML-элемент div с идентификатором house и атрибутом data-tooltip, а в него вложены как HTML-элементы с атрибутом data-tooltip, так и HTML-элементы без атрибута data-tooltip. Причем в один из вложенных HTML-элементов без атрибута data-tooltip (параграф p) вложен HTML-элемент с атрибутом data-tooltip (ссылка a), то есть это уже второй уровень вложенности, если считать от самого внешнего HTML-элемента div.

Я применил на этой странице скрипт-решение для разобранной выше задачи «Поведение "подсказка"». Конечно, он работает. Но в некоторых случаях взаимодействие с браузером происходит не так, как требуется. Я заметил два таких случая.

Во-первых, при перемещении курсора мыши с одного параграфа (HTML-элемент p) на другой параграф сверху вниз (или снизу вверх) подсказка для «дома», то есть для внешнего HTML-элемента div, то появляется, то исчезает. Это происходит потому, что при таком перемещении мыши курсор между параграфами попадает на пространство внешнего HTML-элемента div, у которого есть подсказка, и эта подсказка появляется. При переходе мыши с HTML-элемента div на параграф подсказка скрывается и не появляется, потому что у самих параграфов нет подсказок, хоть они и являются вложенными во внешний HTML-элемент div.

По условиям задачи «Улучшенная подсказка» если курсор мыши наводится на «стопку» HTML-элементов, вложенных друг в друга, то отобразиться должна «самая глубокая» подсказка.

То есть у нас в данном случае «стопка» из двух HTML-элементов, один из которых вложен в другой. Подсказка есть только у «верхнего» (внешнего) HTML-элемента, тут выбирать не из чего, отразиться должна именно она. Таким образом, даже при наведении мыши на внутренний HTML-элемент без подсказки при этом должна отобразиться подсказка внешнего HTML-элемента.

Во-вторых, при переходе с HTML-элемента a (второй уровень вложенности), у которого есть подсказка, на его родительский HTML-элемент p (первый уровень вложенности), у которого нет подсказки, подсказка HTML-элемента a исчезает (так и должно быть), а подсказка HTML-элемента div, в который вложен целевой HTML-элемент p, не появляется (так, по идее, не должно быть).

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

Для решения задачи «Улучшенная подсказка» сначала хочется писать код на каждый случай, внося изменения в обе обрабатывающие события функции showTip и hideTip. Но, на самом деле, достаточно внести небольшие изменения только в функцию showTip. Меняем код:

function showTip(event) {
    // let tar = event.target;
    // if (!tar.dataset.tooltip) return;
    let tar = event.target.closest("[data-tooltip]");
    if (!tar) return;

    tip.innerHTML = tar.dataset.tooltip;
    tip.hidden = false;

    let tarRect = tar.getBoundingClientRect(); // координаты HTML-элемента
    let x, y;                                  // координаты подсказки
                                               // подсказка по центру HTML-элемента
    x = tarRect.x + tar.offsetWidth / 2 - tip.offsetWidth / 2;
    if (x < 0) x = 0;                          // корректируем, если вылезла слева
    
    y = tarRect.y - tip.offsetHeight - 5;      // подсказка над HTML-элементом
    if (y < 0) y = tarRect.y + tar.offsetHeight + 5; // или под ним

    tip.style.left = x + "px";                 // перемещаем подсказку
    tip.style.top = y + "px";                  // в нужное место
}

Я закомментировал первые две строки и вместо них написал две новые (помечены красным). В этом новом коде мы с помощью метода closest ищем в «стопке» вложенных HTML-элементов снизу вверх HTML-элемент с подсказкой (то есть с атрибутом data-tooltip), начиная от того HTML-элемента, на который перешел курсор мыши. Если HTML-элемент с подсказкой в «стопке» найден, то мы покажем подсказку для него, а не для того, на который действительно перешел курсор. В противном случае, если HTML-элемент с подсказкой вообще не найден, наша функция завершит работу, не делая подсказку видимой.

Задача решена.