affix закрепление —— Макет
При прокрутке страницы некоторые элементы нужно «приколоть»: верхнюю навигацию, нижнюю панель действий, боковое оглавление. affix переключает элемент в позиционирование fixed, когда он уходит из вьюпорта, и возвращает обратно при возврате. Сложность не в самом переключении, а в моменте срабатывания и плейсхолдере против схлопывания — core отвечает только за определение и стили, а слежение и плейсхолдер остаются за UI-слоем.
Когда использовать
Заголовок раздела «Когда использовать»- Реализация
<LhAffix>на всех концах: закрепление навигации вверху страницы, панели действий формы внизу, бокового оглавления во вьюпорте. - Сценарии, где элемент должен оставаться видимым во время прокрутки.
- Не следует использовать: на коротких страницах, где элемент и так в зоне видимости (нечего закреплять); для полноэкранных затемнений (используйте fullscreen из
loading); для боковой панели с фиксированной раскладкой (проще напрямую CSSposition: sticky— ценность affix в условном поведении «закрепиться только при достижении порога прокрутки»).
import { getAffixClassNames, isAffixFixed, getAffixFixedStyle,} from '@lh-ui/share/core/affix'import type { LhAffixState } from '@lh-ui/share/core/affix'Замысел
Заголовок раздела «Замысел»top и bottom — два взаимоисключающих режима: при offsetTop > 0 проверяется «вышла ли верхняя грань за верхний порог вьюпорта», при offsetBottom > 0 — «вышла ли нижняя грань за нижний порог вьюпорта». Оба offset не могут действовать одновременно — isAffixFixed при if (offsetBottom > 0) сначала идёт по ветке нижнего режима. Семантически элемент можно закрепить только с одной стороны.
Для нижнего определения обязательно нужна высота вьюпорта: rect.bottom > viewportHeight - offsetBottom — нижняя грань плейсхолдера должна перейти за отметку «низ вьюпорта − порог», чтобы считаться закреплённой. Этот порог динамический (viewportHeight меняется при изменении размера окна), поэтому UI-слой должен передавать текущую высоту вьюпорта при каждом колбэке, без кэширования.
Функция определения отделена от механизма слежения: isAffixFixed принимает прямоугольный объект { top, bottom } и не интересуется, откуда он взялся — колбэк IntersectionObserver получает boundingClientRect из entry, либо отслеживание через resize/scroll-события с getBoundingClientRect() как запасной путь; оба пути используют одну и ту же логику определения.
top: 'auto' / bottom: 'auto' в getAffixFixedStyle: при закреплении вторая ось явно устанавливается в auto, чтобы элемент не имел одновременно и top, и bottom (при fixed-позиционировании значения по обеим осям растягивают высоту элемента).
Имя класса фиксированности, а не прямая правка стилей: lh-affix--fixed оставляет детали fixed-позиционирования (z-index, тень, переход) на SCSS, а getAffixFixedStyle из core считает лишь конкретные пиксели top/bottom — переключатель позиционирования и смещения разделены.
Таблица функций
Заголовок раздела «Таблица функций»| Функция | Сигнатура | Возврат | Назначение |
|---|---|---|---|
getAffixClassNames |
(state: LhAffixState) => string[] |
классы | состояние fixed |
isAffixFixed |
(rect: { top, bottom }, offsetTop, offsetBottom, viewportHeight) => boolean |
закреплён ли | определение выхода из вьюпорта |
getAffixFixedStyle |
(offsetTop, offsetBottom) => Record<string, string> |
инлайновые стили | смещение fixed |
Определения типов
Заголовок раздела «Определения типов»export interface LhAffixState { fixed: boolean // находится ли сейчас в закреплённом состоянии (управляет классом)}Доступность
Заголовок раздела «Доступность»affix — это чисто визуальное позиционирование, не меняющее семантику содержимого, без собственных aria-атрибутов. Две сопутствующие практики:
- В закреплённом состоянии не меняйте порядок DOM: после перехода в fixed элемент остаётся на прежнем месте в DOM, скринридер зачитывает его в порядке исходного кода. Если ради визуала перенести его в конец body (teleport), порядок озвучивания скачет — реализация affix должна менять только позиционирование, не трогая DOM.
- Видимость закреплённого элемента: элемент в закреплённом состоянии всегда находится во вьюпорте,
aria-hiddenне должен меняться при прокрутке (он и так постоянно видим). - Риск ловушки фокуса: закреплённая внизу панель «сохранить / отправить» удобна в длинных формах, но помните, что fixed-элемент может перекрывать содержимое в нижней части страницы (кнопка вечно закрывает последнюю строку) — UI-слой должен добавить
padding-bottomдля body или контейнера содержимого; это проблема раскладки, а не доступности, но она влияет на управляемость.
Примеры использования
Заголовок раздела «Примеры использования»import { isAffixFixed, getAffixFixedStyle } from '@lh-ui/share/core/affix'
// Верхний режим: верхняя грань перешла порог 80pxisAffixFixed({ top: 50, bottom: 200 }, 80, 0, 900) // false (50 < 80 для закрепления, 50 не перешло порог)isAffixFixed({ top: 100, bottom: 300 }, 80, 0, 900) // true (100 > 80)// Направление определения: top < offsetTop истинно — верхняя грань элемента// **выше** пороговой линии (уехала вверх при прокрутке)
// Нижний режим: нижняя грань перешла за «низ вьюпорта − порог»isAffixFixed({ top: 700, bottom: 950 }, 0, 60, 900) // true (950 > 900-60=840)isAffixFixed({ top: 500, bottom: 700 }, 0, 60, 900) // false (700 < 840, ещё не перешла)
// Переданы оба offset: приоритет у нижнегоisAffixFixed({ top: 100, bottom: 950 }, 80, 60, 900) // true (идёт по нижней ветке)getAffixFixedStyle(80, 0) // { position: 'fixed', top: '80px', bottom: 'auto' }getAffixFixedStyle(0, 60) // { position: 'fixed', bottom: '60px', top: 'auto' }getAffixFixedStyle(0, 0) // { position: 'fixed', top: '0px', bottom: 'auto' } (по умолчанию закрепление сверху)// UI-слой: основной путь через IntersectionObserver + плейсхолдер против схлопыванияconst placeholder = document.createElement('div') // в закреплённом состоянии держит исходное место, предотвращая скачки контентаlet observer: IntersectionObserver | undefined
function observe() { observer = new IntersectionObserver( (entries) => { const rect = entries[0].boundingClientRect const fixed = isAffixFixed( { top: rect.top, bottom: rect.bottom }, props.offsetTop ?? 0, props.offsetBottom ?? 0, window.innerHeight, ) if (fixed !== state.fixed) { state.fixed = fixed if (fixed) targetEl.before(placeholder) // плейсхолдер else placeholder.remove() // снять плейсхолдер } }, { threshold: [0, 1] }, ) observer.observe(targetEl)}<!-- Vue3 --><template> <LhAffix :offset-top="64"> <nav class="page-nav">…</nav> </LhAffix></template>// React: useEffect регистрирует IntersectionObserver; при SSR пропускается// (нет IntersectionObserver), подключается после гидратации на клиенте// uniapp: IntersectionObserver на стороне мини-программ — это wx.createIntersectionObserver;// на h5 — нативный API браузера. Функция определения из core общая для обоих концовСтили и токены
Заголовок раздела «Стили и токены»| Класс | Семантика |
|---|---|
.lh-affix |
обёртка (исходное место) |
.lh-affix--fixed |
закреплённое состояние (position: fixed + z-index + опциональные тень/переход) |
z-index — --lh-z-index-affix; тень в закреплённом состоянии — --lh-box-shadow-affix (опционально, при закреплении сверху — тонкая полоса тени снизу для разделения слоёв).
Замечания
Заголовок раздела «Замечания»- Плейсхолдер обязателен: как только элемент становится fixed, он выпадает из потока документа, и последующий контент сдвигается вверх, занимая освободившееся место — страница резко прыгает. UI-слой должен разместить плейсхолдер того же размера (как в примере выше). Высота плейсхолдера должна быть равна высоте элемента до закрепления.
- Высота вьюпорта в нижнем режиме должна быть актуальной: параметр
viewportHeightфункцииisAffixFixedпри каждом вызове должен получать текущее значение, а при resize окна нужно переопределять состояние (affix может перейти из закреплённого обратно в обычное). offsetTop/offsetBottom— только один из двух: при передаче обоих приоритет у нижнего, верхний порог не работает. API хотя и позволяет передать оба, семантически следует настроить только один.- Схлопывание ширины в закреплённом состоянии: после перехода в fixed ширина элемента больше не ограничивается родительским контейнером (block-элемент в fixed сжимается до ширины содержимого). В SCSS обычно записывают и переиспользуют исходную ширину внутри
--fixed, либо UI-слой записывает ширину в инлайновый стиль. - Вложенные прокручиваемые контейнеры: если на странице есть контейнер с локальной прокруткой (а не прокрутка window), root у IntersectionObserver нужно указать как этот контейнер — и параметр высоты вьюпорта у
isAffixFixedнужно заменить на clientHeight контейнера. core этого не различает, согласованность обеспечивает вызывающая сторона. - Совместимость IntersectionObserver: все целевые концы уже поддерживают его; если нужно поддерживать очень старые среды, используйте scroll-события +
getBoundingClientRect()как запасной путь (функцию определения можно переиспользовать напрямую, ведь она принимает только прямоугольный объект). - Выбор между affix и sticky: CSS
position: stickyпокрывает большинство задач «закрепить сверху» и не требует JS. Дополнительная ценность affix — управляемый порог (закрепление не у самого края, а после перехода за 64px) и нижний режим (sticky прилипает только к ближнему краю по направлению прокрутки). В простых сценариях не подключайте affix.
@lh-ui/share/core/affix → isAffixFixed( )(填好参数后点运行)Copyright © 2024-2026 FengMo WenXue