/** * Интерактивность темы TCP UDP Ports. * * Никакого фреймворка: весь HTML приходит с сервера, скрипт только оживляет * уже существующую разметку. Если JS не выполнится, сайт остаётся рабочим — * поиск отправляется обычным POST, FAQ доступен как обычный текст. */ (function () { 'use strict'; var TUPCfg = window.TUP || {}; /* --------------------------------------------------------------------- * Шапка: класс при прокрутке * ------------------------------------------------------------------ */ var header = document.querySelector('[data-header]'); if (header) { var onScroll = function () { header.classList.toggle('is-scrolled', window.scrollY > 20); }; onScroll(); window.addEventListener('scroll', onScroll, { passive: true }); } /* --------------------------------------------------------------------- * Мобильное меню * ------------------------------------------------------------------ */ var toggle = document.querySelector('[data-menu-toggle]'); var mobileNav = document.querySelector('[data-mobile-nav]'); function closeMenu() { if (!toggle || !mobileNav) return; toggle.setAttribute('aria-expanded', 'false'); toggle.setAttribute('aria-label', 'Open menu'); mobileNav.classList.remove('is-open'); document.body.classList.remove('menu-open'); } function openMenu() { if (!toggle || !mobileNav) return; toggle.setAttribute('aria-expanded', 'true'); toggle.setAttribute('aria-label', 'Close menu'); mobileNav.classList.add('is-open'); // Прокрутка страницы под открытым меню блокируется. document.body.classList.add('menu-open'); } if (toggle && mobileNav) { toggle.addEventListener('click', function () { var open = toggle.getAttribute('aria-expanded') === 'true'; if (open) closeMenu(); else openMenu(); }); // Escape закрывает меню. document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && toggle.getAttribute('aria-expanded') === 'true') { closeMenu(); toggle.focus(); } }); // Клик вне панели закрывает меню. document.addEventListener('click', function (e) { if (toggle.getAttribute('aria-expanded') !== 'true') return; if (mobileNav.contains(e.target) || toggle.contains(e.target)) return; closeMenu(); }); // Переход по ссылке внутри меню закрывает его. mobileNav.addEventListener('click', function (e) { if (e.target.closest('a')) closeMenu(); }); // Поворот устройства или переход на десктопную ширину. window.addEventListener('resize', function () { if (window.innerWidth >= 1024) closeMenu(); }); } /* --------------------------------------------------------------------- * FAQ — аккордеон * ------------------------------------------------------------------ */ // .faq-q — внутренние страницы, .faq-r__q — главная (разметка из макета). document.querySelectorAll('.faq-q, .faq-r__q').forEach(function (btn) { btn.addEventListener('click', function () { var expanded = btn.getAttribute('aria-expanded') === 'true'; var panel = document.getElementById(btn.getAttribute('aria-controls')); btn.setAttribute('aria-expanded', expanded ? 'false' : 'true'); if (panel) panel.hidden = expanded; }); }); /* --------------------------------------------------------------------- * Кнопки копирования * ------------------------------------------------------------------ */ document.querySelectorAll('[data-copy], [data-copy-text]').forEach(function (btn) { btn.addEventListener('click', function () { // Кнопка либо копирует соседний блок кода, либо явно заданное значение. var text = btn.getAttribute('data-copy-text'); if (text === null) { var block = btn.closest('.code-block'); var code = block && block.querySelector('code'); if (!code) return; text = code.textContent; } var label = btn.querySelector('.copy-label'); var done = function () { btn.classList.add('is-copied'); if (label) label.textContent = TUPCfg.i18n ? TUPCfg.i18n.copied : 'Copied'; setTimeout(function () { btn.classList.remove('is-copied'); if (label) label.textContent = TUPCfg.i18n ? TUPCfg.i18n.copy : 'Copy'; }, 1800); }; if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text).then(done).catch(fallbackCopy); } else { fallbackCopy(); } function fallbackCopy() { // Локальная разработка идёт по http, где Clipboard API недоступен. var ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); done(); } catch (err) { /* копирование недоступно — молча ничего не делаем */ } document.body.removeChild(ta); } }); }); /* --------------------------------------------------------------------- * Кнопка «наверх» * ------------------------------------------------------------------ */ var toTop = document.querySelector('[data-to-top]'); if (toTop) { var toggleTop = function () { toTop.classList.toggle('is-visible', window.scrollY > 600); }; toggleTop(); window.addEventListener('scroll', toggleTop, { passive: true }); toTop.addEventListener('click', function () { var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; window.scrollTo({ top: 0, behavior: reduce ? 'auto' : 'smooth' }); // Фокус возвращается в начало страницы, а не теряется. var main = document.getElementById('main'); if (main) { main.setAttribute('tabindex', '-1'); main.focus({ preventScroll: true }); } }); } /* --------------------------------------------------------------------- * Поиск с подсказками * ------------------------------------------------------------------ */ function escapeHtml(s) { return String(s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); } function highlight(text, query) { var safe = escapeHtml(text); if (!query) return safe; var i = safe.toLowerCase().indexOf(query.toLowerCase()); if (i === -1) return safe; return ( safe.slice(0, i) + '' + safe.slice(i, i + query.length) + '' + safe.slice(i + query.length) ); } document.querySelectorAll('[data-search-form]').forEach(function (form, formIndex) { var input = form.querySelector('[data-search-input]'); var box = form.querySelector('[data-search-suggest]'); if (!input || !box || !TUPCfg.restUrl) return; var timer = null; var activeIndex = -1; var items = []; var lastQuery = ''; var idBase = 'tup-opt-' + formIndex + '-'; function close() { box.classList.remove('is-open'); box.innerHTML = ''; input.setAttribute('aria-expanded', 'false'); input.removeAttribute('aria-activedescendant'); activeIndex = -1; items = []; } /** * Подгоняет список под свободное место. * * Снизу места хватает — открывается вниз. Если снизу тесно, а сверху * просторнее, список разворачивается вверх: иначе он уходит за нижний * край экрана, и последние результаты недоступны. */ function fitToViewport() { var rect = input.getBoundingClientRect(); var gap = 8; var below = window.innerHeight - rect.bottom - gap - 8; var above = rect.top - gap - 8; var flip = below < 200 && above > below; var room = Math.max(120, Math.min(360, flip ? above : below)); box.style.maxHeight = room + 'px'; box.classList.toggle('is-above', flip); } function setActive(i) { items.forEach(function (li) { li.classList.remove('is-active'); li.setAttribute('aria-selected', 'false'); }); activeIndex = i; if (i < 0) { input.removeAttribute('aria-activedescendant'); return; } var li = items[i]; li.classList.add('is-active'); li.setAttribute('aria-selected', 'true'); input.setAttribute('aria-activedescendant', li.id); li.scrollIntoView({ block: 'nearest' }); } function open() { box.classList.add('is-open'); input.setAttribute('aria-expanded', 'true'); fitToViewport(); } function render(results, query, corrected) { if (!results.length) { var msg = corrected ? 'No matches. Did you mean ' + escapeHtml(corrected) + '?' : escapeHtml(TUPCfg.i18n ? TUPCfg.i18n.noResults : 'No matches found'); box.innerHTML = '