VGSpy

Анимированный скроллинг шпион

API и события

Динамические секции, refresh, dispose и журнал активации.

VGSpy добавляет класс active ссылке текущей секции. Прокрутите область примера или выберите пункт навигации. Каждый пример независим; оформление активных ссылок задаёт приложение.

Динамический документ

Добавьте раздел, выберите его, удалите последний или очистите документ. После изменений вызывается refresh(). Отключение освобождает экземпляр, повторное включение создаёт новый.

Подготовка примера…

Разделов нет. Нажмите «Добавить раздел».

Журнал activate.vg.spy
    HTML
    <div class="spy-demo" id="spy-api-demo">
        <div class="spy-demo__controls">
            <button class="btn btn-primary btn-sm" type="button" id="spy-api-add">Добавить раздел</button>
            <button class="btn btn-surface btn-sm" type="button" id="spy-api-remove">Удалить последний</button>
            <button class="btn btn-surface btn-sm" type="button" id="spy-api-clear">Очистить документ</button>
            <button class="btn btn-surface btn-sm" type="button" id="spy-api-toggle">Отключить VGSpy</button>
        </div>
        <p id="spy-api-status" role="status">Подготовка примера…</p>
        <nav class="spy-demo__nav" id="spy-api-nav" aria-label="Динамические разделы"></nav>
        <div class="spy-demo__scroll" id="spy-api-scroll" tabindex="0" role="region" aria-label="Динамический документ">
            <p class="spy-demo__empty" id="spy-api-empty">Разделов нет. Нажмите «Добавить раздел».</p>
        </div>
        <div>
            <strong>Журнал activate.vg.spy</strong>
            <ol class="spy-demo__log" id="spy-api-log" aria-live="polite"></ol>
        </div>
    </div>
    JavaScript
    /**
     * Описание: жизненный цикл VGSpy на динамическом документе.
     * Возможности: добавление и удаление секций, пустое состояние, refresh, dispose и события.
     */
    import { VGSpy } from 'vgapp';
    
    function initSpyDemo() {
        const demo = document.getElementById('spy-api-demo');
        if (!demo || demo.dataset.initialized) return;
        demo.dataset.initialized = 'true';
    
        const nav = document.getElementById('spy-api-nav');
        const root = document.getElementById('spy-api-scroll');
        const empty = document.getElementById('spy-api-empty');
        const status = document.getElementById('spy-api-status');
        const log = document.getElementById('spy-api-log');
        const add = document.getElementById('spy-api-add');
        const remove = document.getElementById('spy-api-remove');
        const clear = document.getElementById('spy-api-clear');
        const toggle = document.getElementById('spy-api-toggle');
        let instance = null;
        let sequence = 0;
    
        const update = () => {
            const count = nav.children.length;
            empty.hidden = count > 0;
            remove.disabled = clear.disabled = count === 0;
            add.disabled = count >= 6;
            toggle.textContent = instance ? 'Отключить VGSpy' : 'Включить VGSpy';
            status.textContent = (instance ? 'VGSpy включён' : 'VGSpy отключён') + ' · Разделов: ' + count;
        };
        const appendSection = () => {
            if (nav.children.length >= 6) return;
            const number = ++sequence;
            const section = document.createElement('section');
            section.id = 'spy-api-section-' + number;
            section.className = 'spy-demo__section';
            const heading = document.createElement('h4');
            heading.textContent = 'Раздел ' + number;
            const text = document.createElement('p');
            text.textContent = 'Этот раздел добавлен в DOM. После вставки refresh() обновляет ссылки и наблюдение.';
            section.append(heading, text);
            root.append(section);
            const link = document.createElement('button');
            link.type = 'button';
            link.dataset.vgTarget = '#' + section.id;
            link.textContent = heading.textContent;
            nav.append(link);
            update();
            instance?.refresh();
        };
    
        // Слушатель приложения сохраняется при dispose() и повторном создании VGSpy.
        nav.addEventListener('activate.vg.spy', event => {
            const item = document.createElement('li');
            item.textContent = event.relatedTarget.textContent + ' · ' + event.relatedTarget.dataset.vgTarget;
            log.prepend(item);
            while (log.children.length > 8) log.lastElementChild.remove();
        });
        appendSection();
        appendSection();
        instance = VGSpy.getOrCreateInstance(nav, { target: root });
        update();
    
        add.addEventListener('click', appendSection);
        remove.addEventListener('click', () => {
            const link = nav.lastElementChild;
            if (!link) return;
            document.querySelector(link.dataset.vgTarget)?.remove();
            link.remove();
            update();
            instance?.refresh();
        });
        clear.addEventListener('click', () => {
            nav.replaceChildren();
            root.querySelectorAll('.spy-demo__section').forEach(section => section.remove());
            update();
            instance?.refresh();
        });
        toggle.addEventListener('click', () => {
            if (instance) {
                instance.dispose();
                instance = null;
            } else {
                instance = VGSpy.getOrCreateInstance(nav, { target: root });
            }
            update();
        });
    }
    
    initSpyDemo();
    CSS
    /* Композиция демо; семантические токены OKAUX на сайте используют префикс vg. */
    .spy-demo {
        display: grid;
        gap: 1rem;
        min-width: 0;
    }
    .spy-demo__nav, .spy-demo__controls {
        display: flex;
        flex-wrap: wrap;
        align-items: flex-start;
        gap: .5rem;
    }
    .spy-demo__nav a, .spy-demo__nav button {
        display: inline-flex;
        padding: .5rem .75rem;
        border: 1px solid var(--vg-border-default);
        border-radius: .5rem;
        background: var(--vg-background-primary);
        color: var(--vg-text-primary);
        text-decoration: none;
        font: inherit;
    }
    .spy-demo__nav .active {
        background: var(--vg-background-tertiary);
        border-color: var(--vg-border-active);
        box-shadow: inset 0 -3px var(--vg-border-active);
        font-weight: 700;
    }
    .spy-demo__nav :is(a, button):focus-visible {
        outline: 2px solid var(--vg-border-active);
        outline-offset: 2px;
    }
    .spy-demo__nav .nav {
        display: grid;
        gap: .25rem;
        padding: .5rem 0 0 1rem;
    }
    .spy-demo__nav .vg-dropdown-container {
        display: grid;
        gap: .25rem;
        padding: .5rem;
    }
    .spy-demo__scroll {
        height: 20rem;
        overflow-y: auto;
        overscroll-behavior: contain;
        border: 1px solid var(--vg-border-default);
        border-radius: .75rem;
        background: var(--vg-background-primary);
        color: var(--vg-text-primary);
    }
    .spy-demo__section {
        min-height: 18rem;
        padding: 1.25rem;
        border-bottom: 1px solid var(--vg-border-default);
        overflow-wrap: anywhere;
    }
    .spy-demo__section:nth-child(even) { background: var(--vg-background-secondary); }
    .spy-demo__section h4 { margin: 0 0 .75rem; }
    .spy-demo__log {
        max-height: 10rem;
        overflow: auto;
        margin: 0;
        padding-left: 1.5rem;
        color: var(--vg-text-secondary);
        overflow-wrap: anywhere;
    }
    .spy-demo__empty { padding: 1.25rem; color: var(--vg-text-secondary); }
    @media (max-width: 575px) {
        .spy-demo__scroll { height: 17rem; }
        .spy-demo__section { min-height: 16rem; padding: 1rem; }
    }

    Методы и события

    VGSpy.getOrCreateInstance(nav, options)
    Возвращает существующий экземпляр или создаёт новый. Повторный вызов не применяет новые options.
    VGSpy.getInstance(nav)
    Возвращает экземпляр или null. Храните только действующую ссылку; после dispose() создайте новый экземпляр.
    refresh()
    Повторно находит ссылки и секции, определяет контейнер, пересоздаёт наблюдение. Вызывайте после добавления, удаления или раскрытия содержимого.
    dispose()
    Отключает observer, подписку Smooth Scrollbar и обработчики модуля; удаляет экземпляр из реестра. Обработчики приложения, добавленные через addEventListener, приложение удаляет самостоятельно.
    activate.vg.spy
    DOM-событие на навигации при смене активного пункта. Ссылка доступна в event.relatedTarget, не в event.detail. Отдельного события деактивации и callback onActivate нет.

    Интеграция с Smooth Scrollbar

    Это необязательная внешняя зависимость; в демо она не установлена. VGSpy читает экземпляр из target.scrollbar либо window.Scrollbar.get(target). Сначала создайте Scrollbar, затем VGSpy. При модульном импорте сохраните результат Scrollbar.init(root) в root.scrollbar и передайте root как target.

    Отдельного работающего параметра scrollbar в текущем исходнике VGSpy нет. В виртуальном режиме используется listener Scrollbar вместо IntersectionObserver; threshold не участвует, rootMargin задаёт линию активации. Уничтожение внешнего Scrollbar остаётся ответственностью приложения, после dispose() VGSpy.