Поиск H1-H6 на странице [A11Y]

Откуда идея и востребованность

Одна из важных тем в обеспечении доступности сайта (а за одно и СЕО) - правильные заголовки и их последовательная иерархия.

Из скрин-ридеров я использовал только NVDA, но однажды смотрел описание VoiceOver и увидел, как он выводит диалог со списком заголовков на странице. В NVDA такой опции я не нашел, но зато наткнулся на примерно такой код-сниппет для консоли:

for (var i = 0, headings = $$('h1,h2,h3,h4,h5,h6');
    i < headings.length; i++) {
       console.log('<'+headings[i].tagName +'> ' + headings[i].textContent.trim());
}

Минималистично и просто, но не достаточно удобно на мой взгляд. И я решил добавить немного UI.

Доработка

В результате вышел вот такой букмарклет: H1-H6. Эту ссылку можно просто нажать, чтобы посмотреть результат, а лучше - перетянуть в панель закладок, чтобы использовать на любой странице.

Скрытый заголовок

В результате вы должны увидеть примерно следующий попап внизу страницы:

Результат работы скрипта по поискам заголовков
  • Если кликнуть вне попапа, он закроется.
  • Клик по одному из заголовков прокрутит страницу к нужному заголовку и подсветит его.
  • При клике на заголовки (даже скрытые), элемент заголовка логируется в консоль и вы можете перейти к нему в коде:
Переход к элементу из консоли

Код в читабельном виде:

var css = `
#e404h_overlay {position:fixed; bottom:0; width:80%; max-height:200px; overflow:auto; padding:15px;}
.e404h_i:hover {background: rgba(255,0,0,.2); cursor:pointer;}
.e404h_i.e404h_hidden {opacity:.2; cursor:not-allowed;}
.e404h_i.e404h_hidden::after {content:' [hidden]';}
.e404h_h1 {font-size: 30px; line-height: 1.1;}
.e404h_h2 {font-size: 25px; line-height: 1.1;}
.e404h_h3 {font-size: 20px; line-height: 1.2;}
.e404h_h4 {font-size: 17px; line-height: 1.3;}
.e404h_h5 {font-size: 14px; line-height: 1.3;}
.e404h_h6 {font-size: 11px; line-height: 1.3;}
.e404h_hlight {box-shadow: 0 0 3px 1px #f00;}
`;

var html = '';
var headings = document.querySelectorAll('h1,h2,h3,h4,h5,h6');
var heads = [];
for (var i = 0; i < headings.length; i++) {
    heads.push({
        tag: headings[i].tagName.toLowerCase(),
        text: headings[i].textContent.trim(),
        elem: headings[i]
    });
}
heads.forEach((h,i)=>{
   let hidden = h.elem.offsetParent ? '' : ' e404h_hidden';
   html += `<div class="e404h_i e404h_${h.tag}${hidden}" data-i="${i}">${h.tag}: ${h.text}</div>`;
});

var wrap = document.getElementById('e404h_overlay');
if(wrap){
    wrap.outherHTML = '';
} else {
    var style = document.createElement('style');
    style.id = 'e404h_style';
    style.innerHTML = css;
    document.body.appendChild(style);
}
wrap = document.createElement('dialog');
wrap.id='e404h_overlay';
wrap.innerHTML = html;
document.body.appendChild(wrap);
wrap.showModal();

wrap.addEventListener('click', function(e){
    if(e.target.classList.contains('e404h_i')){
        var ind = e.target.getAttribute('data-i')*1;
        var el = heads[ind].elem;
        console.log(el);
        if(e.target.classList.contains('e404h_hidden')){ return; }
        var dy = el.getBoundingClientRect().y;
        window.scroll(0, window.pageYOffset+dy-100);
        el.classList.add('e404h_hlight');
        setTimeout(()=>{ el.classList.remove('e404h_hlight') },1000)
    } else if(e.target.nodeName === 'DIALOG'){
        this.close();
    }
});

Особенности реализации

Для тех, кто дочитал до конца :)

Элемент <dialog> я никогда раньше не использовал и не знал о нем. Но раз узнал - решил воспользоваться :)

Внутри всё на <div> - это тоже умышленно. Т.к. код вставляется на страницы разных сайтов, то использование разметки, лишенной какой-либо семантики уменьшает шансы коллизий стилей.

Удаление элемента и повторное добавление обработчика при каждой активации - это спасает на динамических страницах, т.к. при повторном вызове бывали осложнения.