Демо для фронтенда · каталог Randewoo
Все чипсы всегда в DOM — SSR отдаёт их для SEO. Сколько показывать, решает браузер: чипсы, перенесённые flex-wrap'ом на обрезанную вторую строку, помечаются скрытыми. Меняйте ширину, активируйте фильтры и включите рентген, чтобы увидеть скрытое. Пара к демо — чеклист SEO и реализации.
Клик по чипсу — активировать · клик по активному — добавить значение (чипс расширяется) · клик по ✕ — снять фильтр
Пунктир — элемент есть в DOM, но скрыт: полупрозрачный — обрезан клэмпом (visibility:hidden), перечёркнутый — схлопнут ради активного (display:none), пунктирная «Очистить» — стелс-слот.
SSR отдаёт все 11 ссылок-фильтров — SEO видит каждую. Лишние переносятся flex-wrap'ом на вторую строку, которую обрезает max-height списка. Первый кадр корректен без единой строчки JS — скелетон и прыжки вёрстки не нужны.
Ширины никто не суммирует: браузер уже разложил чипсы. Кто перенёсся (offsetTop больше, чем у первого ряда) — тот не влез. Триггеры пересчёта: маунт, ResizeObserver, изменение активных фильтров, document.fonts.ready.
Невлезший хвост прячется через visibility:hidden + aria-hidden + tabindex="-1" — чипсы остаются в раскладке, замер стабилен. Но если за границу вылетел активный чипс — сначала ему отдаётся резерв 0,5× у «Очистить», а если и этого мало, схлопываем (display:none) последний неактивный перед ним — и активный поднимается в первый ряд.
Кнопка всегда занимает место (visibility:hidden пока фильтров нет), поэтому её появление не сдвигает ни один чипс. Резерв слота — 1,5 ширины кнопки: кнопка + 0,5 её ширины воздуха. Резерв мягкий (план А): когда активные чипсы расширяются, сначала они съедают этот воздух — и ни один фильтр не пропадает. Схлопывание неактивных включается только после исчерпания резерва (план Б). Неактивный хвост в резерв не пускаем — без давления активных зазор всегда ≥ 0,5 ширины кнопки.
function usePopularFilters(listRef, clearRef, activeKeys) {
useLayoutEffect(() => {
const list = listRef.current;
if (!list) return;
const scan = (chips) => {
const inLayout = chips.filter((c) => c.dataset.state !== 'collapsed');
const base = Math.min(...inLayout.map((c) => c.offsetTop));
return { inLayout, wrapped: inLayout.filter((c) => c.offsetTop > base) };
};
const isActive = (c) => c.dataset.active === 'true';
const measure = () => {
const chips = Array.from(list.children);
chips.forEach(reset); // все чипсы обратно в раскладку
const reserve = Math.round(clearRef.current.offsetWidth * 0.5);
clearRef.current.style.marginLeft = `${reserve}px`; // план А: резерв 0,5× держит воздух
let { wrapped } = scan(chips);
let wrappedActive = wrapped.filter(isActive);
if (wrapped.length && !wrappedActive.length) { // не влез только хвост неактивных:
wrapped.forEach(clip); // клипуем, резерв НЕ трогаем
return;
}
if (!wrappedActive.length) return; // всё влезло
clearRef.current.style.marginLeft = '0px'; // активный не влез: отдаём ему резерв
for (let guard = 0; guard <= chips.length; guard++) {
const r = scan(chips);
if (!r.wrapped.length) break; // хватило резерва — план Б не нужен
wrappedActive = r.wrapped.filter(isActive);
if (!wrappedActive.length) { r.wrapped.forEach(clip); break; }
const i = r.inLayout.indexOf(wrappedActive[0]); // план Б: резерв исчерпан —
const victim = r.inLayout.slice(0, i).reverse() // жертвуем последним неактивным
.find((c) => !isActive(c));
if (!victim) { // одни активные не влезают:
r.wrapped.filter((c) => !isActive(c)).forEach(clip);
break; // предел — нужна отдельная строка активных
}
collapse(victim); // display:none → активный поднимется, меряем снова
}
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(list.parentElement); // ресайз контейнера, не окна
document.fonts?.ready.then(measure); // шрифт догрузился → ширины изменились
return () => ro.disconnect();
}, [activeKeys]); // пересчёт при каждой (де)активации
}
// reset(c) → state='', убрать aria-hidden / tabindex
// clip(c) → state='clipped' (CSS: visibility:hidden) + aria-hidden + tabindex=-1
// collapse(c) → state='collapsed'(CSS: display:none) + aria-hidden
Смысл всей схемы: краулер получает полный список фильтров в первом HTML, скрытие — только косметика на клиенте.
<a href> на реальный URL фильтра.Не <button onClick> и не <div>. SPA-переход можно перехватывать через onClick + preventDefault, но href обязан быть — иначе краулеру нечего обходить и SSR теряет смысл. (В этом демо чипсы — кнопки, потому что это макет.)visibility:hidden, жертва ради активного → display:none. Оба варианта остаются в DOM: контент индексируется, ссылки краулятся (вес скрытого текста у Google ниже — для задачи «найти страницы фильтров» этого достаточно).{visible && <Chip/>}.После гидрации ссылки исчезнут из DOM. Скрытие только через data-state/класс, элемент рендерится всегда.overflow:hidden) уже прячет лишнее без JS. Никакого CLS.curl / view-source содержит все <a> фильтров; с отключённым JS строка выглядит корректно (одна строка, ничего не торчит).