Дропдауны и меню

Дропдауны и меню в экосистеме SvelteKit реализуются через композицию реактивных состояний, DOM-позиционирования и управления фокусом. В отличие от традиционных UI-фреймворков, Svelte позволяет минимизировать абстракции и работать напрямую с поведением компонентов, что особенно важно для интерактивных элементов.

Ключевые составляющие любого дропдауна:

  • Триггер (trigger) — элемент, который открывает меню
  • Контейнер меню (content/panel) — отображаемый блок
  • Состояние (open/closed) — реактивная переменная
  • Обработка кликов вне области (click outside)
  • Управление фокусом и клавиатурой

Базовая реализация дропдауна

Минимальная реализация опирается на реактивность Svelte:

<script>
    let open = false;

    function toggle() {
        open = !open;
    }

    function close() {
        open = false;
    }
</script>

<div class="dropdown">
    <button on:click={toggle}>
        Меню
    </button>

    {#if open}
        <div class="menu">
            <a href="/profile">Профиль</a>
            <a href="/settings">Настройки</a>
            <button on:click={close}>Выход</button>
        </div>
    {/if}
</div>

Особенности:

  • #if удаляет DOM при закрытии → экономия ресурсов
  • логика максимально прозрачна
  • отсутствует контроль фокуса и позиционирования

Обработка клика вне компонента

Без закрытия по клику вне области дропдаун считается неполноценным. Реализация через кастомный action:

export function clickOutside(node) {
    function handleClick(event) {
        if (!node.contains(event.target)) {
            node.dispatchEvent(new CustomEvent('outclick'));
        }
    }

    document.addEventListener('click', handleClick, true);

    return {
        destroy() {
            document.removeEventListener('click', handleClick, true);
        }
    };
}

Использование:

<script>
    import { clickOutside } from './clickOutside';
    let open = false;
</script>

<div use:clickOutside on:outclick={() => (open = false)}>
    <button on:click={() => (open = !open)}>Меню</button>

    {#if open}
        <div class="menu">...</div>
    {/if}
</div>

Управление фокусом и доступность (A11y)

Дропдауны должны соответствовать стандартам доступности:

  • aria-expanded
  • aria-haspopup
  • управление клавиатурой (Enter, Escape, Arrow keys)

Пример:

<button
    aria-haspopup="true"
    aria-expanded={open}
    on:click={toggle}
    on:keydown={(e) => {
        if (e.key === 'Escape') open = false;
    }}
>
    Меню
</button>

Навигация по пунктам:

<div role="menu">
    <button role="menuitem">Профиль</button>
    <button role="menuitem">Настройки</button>
</div>

Позиционирование меню

Проблема: дропдаун должен корректно отображаться относительно триггера.

Подходы:

1. CSS-only

.dropdown {
    position: relative;
}

.menu {
    position: absolute;
    top: 100%;
    left: 0;
}

Недостатки:

  • не учитывает границы viewport
  • нет адаптации

2. Использование Floating UI

Библиотеки UI (например Skeleton, Melt UI) используют Floating UI для динамического позиционирования:

import { computePosition } from '@floating-ui/dom';

Преимущества:

  • авто-переворот (flip)
  • смещение (offset)
  • предотвращение выхода за экран

Переходы и анимации

Svelte предоставляет встроенные transition-функции:

<script>
    import { fade, scale } from 'svelte/transition';
</script>

{#if open}
    <div transition:fade>
        Меню
    </div>
{/if}

Комбинированный вариант:

<div in:scale={{ duration: 150 }} out:fade>
    Меню
</div>

Практика:

  • короткие анимации (100–200ms)
  • отсутствие задержек при закрытии
  • избегание layout shift

Композиция через слоты

UI-библиотеки строят дропдауны как composable-компоненты:

<Dropdown>
    <Dropdown.Trigger>
        Кнопка
    </Dropdown.Trigger>

    <Dropdown.Content>
        <Dropdown.Item>Пункт 1</Dropdown.Item>
    </Dropdown.Content>
</Dropdown>

Внутри реализуется:

  • контекст (setContext, getContext)
  • синхронизация состояния
  • изоляция логики

Контекст для управления состоянием

import { setContext, getContext } from 'svelte';

const KEY = {};

export function createDropdown() {
    let open = false;

    function toggle() {
        open = !open;
    }

    setContext(KEY, { open, toggle });
}

export function useDropdown() {
    return getContext(KEY);
}

Вложенные меню (nested dropdowns)

Сложность:

  • управление несколькими состояниями
  • предотвращение закрытия родителя

Подход:

  • отдельные контексты
  • stopPropagation
  • delay hover (для hover-меню)

Hover vs Click

Click-based

Плюсы:

  • мобильная совместимость
  • предсказуемость

Hover-based

Плюсы:

  • быстрее навигация

Минусы:

  • проблемы на тач-устройствах
  • необходимость debounce

Пример hover:

<div
    on:mouseenter={() => (open = true)}
    on:mouseleave={() => (open = false)}
>

Headless UI-подход

Популярные библиотеки (Melt UI, Radix-подобные решения) используют headless-подход:

  • нет стилей
  • только логика и accessibility
  • полный контроль над внешним видом

Пример API:

<script>
    import { createDropdown } from '@melt-ui/svelte';
    const dropdown = createDropdown();
</script>

<button use:dropdown.trigger>
    Меню
</button>

<div use:dropdown.content>
    Элементы
</div>

Порталы (teleport)

Дропдауны часто рендерятся вне DOM-иерархии для избежания overflow:

<svelte:body>
    {#if open}
        <div class="portal-menu">...</div>
    {/if}
</svelte:body>

Проблемы, которые решаются:

  • z-index конфликты
  • clipping родителями

Управление состоянием через store

Для глобальных меню:

import { writable } from 'svelte/store';

export const dropdownStore = writable(null);

Использование:

  • только одно меню открыто
  • синхронизация между компонентами

Оптимизация производительности

  • удаление DOM через {#if}
  • минимизация listeners
  • lazy rendering сложных меню
  • избегание лишних реактивных зависимостей

Частые ошибки

  • отсутствие обработки Escape
  • неправильный focus trapping
  • конфликт кликов (event bubbling)
  • фиксированное позиционирование без адаптации
  • игнорирование мобильных устройств

Практика UI-библиотек

Типичные возможности:

  • keyboard navigation (ArrowUp/Down)
  • typeahead поиск
  • disabled items
  • separators
  • group labels

Расширенные возможности

1. Typeahead (поиск по первым буквам)

let buffer = '';

function handleKey(e) {
    buffer += e.key;
}

2. Controlled vs uncontrolled

  • controlled — состояние управляется извне
  • uncontrolled — внутри компонента

3. Multi-select dropdown

  • хранение массива значений
  • чекбоксы внутри меню

Структура идеального дропдауна

  • независимый trigger
  • headless логика
  • адаптивное позиционирование
  • полная клавиатурная поддержка
  • порталы
  • контроль фокуса
  • гибкая композиция через слоты

Такой подход формирует основу большинства современных UI-библиотек для SvelteKit и позволяет создавать масштабируемые, доступные и гибкие интерфейсные компоненты.