Демо для фронтенда · каталог Randewoo

Строка популярных фильтров: клэмп, замер по переносу, стелс-«Очистить»

Все чипсы всегда в DOM — SSR отдаёт их для SEO. Сколько показывать, решает браузер: чипсы, перенесённые flex-wrap'ом на обрезанную вторую строку, помечаются скрытыми. Меняйте ширину, активируйте фильтры и включите рентген, чтобы увидеть скрытое. Пара к демо — чеклист SEO и реализации.

1160 px

Клик по чипсу — активировать · клик по активному — добавить значение (чипс расширяется) · клик по ✕ — снять фильтр

Пунктир — элемент есть в DOM, но скрыт: полупрозрачный — обрезан клэмпом (visibility:hidden), перечёркнутый — схлопнут ради активного (display:none), пунктирная «Очистить» — стелс-слот.

Состояние

ширина блока, px
видимых чипсов
активных фильтров
обрезано клэмпом (в DOM)
схлопнуто ради активных
кнопка «Очистить»
резерв 0,5× (план А)

Журнал замеров

1Всё в DOM с сервера

SSR отдаёт все 11 ссылок-фильтров — SEO видит каждую. Лишние переносятся flex-wrap'ом на вторую строку, которую обрезает max-height списка. Первый кадр корректен без единой строчки JS — скелетон и прыжки вёрстки не нужны.

2Замер — по offsetTop

Ширины никто не суммирует: браузер уже разложил чипсы. Кто перенёсся (offsetTop больше, чем у первого ряда) — тот не влез. Триггеры пересчёта: маунт, ResizeObserver, изменение активных фильтров, document.fonts.ready.

3Активный не скрывается

Невлезший хвост прячется через visibility:hidden + aria-hidden + tabindex="-1" — чипсы остаются в раскладке, замер стабилен. Но если за границу вылетел активный чипс — сначала ему отдаётся резерв 0,5× у «Очистить», а если и этого мало, схлопываем (display:none) последний неактивный перед ним — и активный поднимается в первый ряд.

4«Очистить» — стелс-слот 1,5×, резерв мягкий

Кнопка всегда занимает место (visibility:hidden пока фильтров нет), поэтому её появление не сдвигает ни один чипс. Резерв слота — 1,5 ширины кнопки: кнопка + 0,5 её ширины воздуха. Резерв мягкий (план А): когда активные чипсы расширяются, сначала они съедают этот воздух — и ни один фильтр не пропадает. Схлопывание неактивных включается только после исчерпания резерва (план Б). Неактивный хвост в резерв не пускаем — без давления активных зазор всегда ≥ 0,5 ширины кнопки.

React-хук с этим же алгоритмом
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

SEO: условия, при которых схема работает критично

Смысл всей схемы: краулер получает полный список фильтров в первом HTML, скрытие — только косметика на клиенте.