липкая панель прячется, пока герой в кадре
All checks were successful
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m37s

Владелец увидел на первом экране, что полоса «Проверить квартиру» лежит поверх
абзаца «Мы называем реальную цену вашей квартиры».

.barSpacer это НЕ лечит и не может: он добавляет высоту в КОНЕЦ документа, а
.barRoot — fixed, то есть накрывает нижние ~67px вьюпорта при ЛЮБОЙ прокрутке,
включая scrollTop=0. Разобравший это агент так и написал и не стал чинить молча.

Пока герой виден, панель не нужна: прямо в нём стоит та же форма проверки, и
полоса дублирует призыв, закрывая текст. Уходит герой — панель появляется.

ПО УМОЛЧАНИЮ ПАНЕЛЬ ВИДИМА. Скрытие включает только клиентский наблюдатель;
если скрипт не выполнился, поведение остаётся ровно сегодняшним. Обратный
порядок (скрыта, показывает скрипт) в тех же условиях убрал бы призыв со
страницы совсем — то есть чинил бы вид ценой работы. Нет секции героя —
наблюдать нечего, панель остаётся видимой, а не пропадает.

Убирается transform-ом, а не display:none: transform идёт на композиторе и не
вызывает перекладку. Страница и так дорога в отрисовке на мобильном (1824 мс в
Style & Layout), и лечение перекрытия не должно стоить пересчёта макета на
каждом пересечении границы героя.

Убранная панель уходит и из дерева доступности (aria-hidden + inert): иначе Tab
уводит в кнопку, которой на экране нет.

Тест проверяет ПОВЕДЕНИЕ, а не наличие класса в файле: наблюдателю скармливается
пересечение. Фальсификация обеих сторон — панель не прячется («expected false to
be true») и панель скрыта по умолчанию («до срабатывания панель видима»).
150 passed, eslint чисто.
This commit is contained in:
bot-backend 2026-08-31 14:13:36 +05:00
parent da6a6b6e82
commit 47df1a9020
3 changed files with 160 additions and 1 deletions

View file

@ -0,0 +1,92 @@
/**
* Липкая панель не должна лежать поверх первого экрана.
*
* Владелец увидел на проде, что полоса накрывает абзац «Мы называем реальную
* цену вашей квартиры». `.barSpacer` это не лечит и не может: он добавляет
* высоту в КОНЕЦ документа, а `.barRoot` `fixed`, то есть накрывает нижние
* ~67px вьюпорта при любой прокрутке, включая самый верх страницы.
*
* Проверяется ПОВЕДЕНИЕ, а не наличие класса в файле: наблюдателю скармливается
* пересечение и проверяется, что панель убралась, затем обратное.
*/
import { render, screen } from "@testing-library/react";
import { act } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StickyCtaV3 } from "../_components/v3/StickyCtaV3";
let срабатывание: ((entries: { isIntersecting: boolean }[]) => void) | null = null;
beforeEach(() => {
срабатывание = null;
vi.stubGlobal(
"IntersectionObserver",
class {
constructor(cb: (entries: { isIntersecting: boolean }[]) => void) {
срабатывание = cb;
}
observe() {}
disconnect() {}
},
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
/**
* Корень панели ищем ПО КОНТЕЙНЕРУ, а не через getByRole: когда панель убрана,
* на ней стоит aria-hidden, и роль «ссылка» из дерева доступности пропадает
* это и есть желаемое поведение, но локатор по роли на нём слепнет.
*/
function корень(c: HTMLElement): HTMLElement {
const link = c.querySelector('a[href*="estimate"]');
if (!link?.parentElement) throw new Error("панель не отрендерилась");
return link.parentElement;
}
const скрыта = (c: HTMLElement) => /barHidden/u.test(корень(c).className);
describe("липкая панель против первого экрана", () => {
it("без секции героя панель остаётся видимой, а не пропадает", () => {
// Наблюдать нечего — поведение обязано остаться сегодняшним.
const { container } = render(<StickyCtaV3 />);
expect(скрыта(container)).toBe(false);
});
it("герой в кадре — панель убрана и недоступна с клавиатуры", () => {
const hero = document.createElement("section");
hero.id = "check";
document.body.appendChild(hero);
const { container } = render(<StickyCtaV3 />);
expect(скрыта(container), "до срабатывания панель видима").toBe(false);
act(() => срабатывание!([{ isIntersecting: true }]));
expect(скрыта(container)).toBe(true);
expect(корень(container).getAttribute("aria-hidden")).toBe("true");
// Убранная панель обязана уйти и из дерева доступности — иначе Tab
// уводит в кнопку, которой на экране нет.
expect(screen.queryByRole("link", { name: /Проверить квартиру/u })).toBeNull();
hero.remove();
});
it("герой ушёл — панель возвращается", () => {
const hero = document.createElement("section");
hero.id = "check";
document.body.appendChild(hero);
const { container } = render(<StickyCtaV3 />);
act(() => срабатывание!([{ isIntersecting: true }]));
expect(скрыта(container)).toBe(true);
act(() => срабатывание!([{ isIntersecting: false }]));
expect(скрыта(container), "панель не вернулась — призыв потерян навсегда").toBe(false);
expect(screen.getByRole("link", { name: /Проверить квартиру/u })).toBeTruthy();
hero.remove();
});
});

View file

@ -1,3 +1,5 @@
"use client";
/**
* StickyCtaV3 липкая нижняя CTA-панель (макет ~ строки 518-521).
*
@ -32,17 +34,64 @@
* чаще всего. В тексте панели по той же причине нет и «срока продажи»:
* платный отчёт отдаёт медиану экспозиции похожих объявлений, а не прогноз
* срока (`_estimate_days_on_market`, см. HeroV3).
*
* ПАНЕЛЬ ПРЯЧЕТСЯ, ПОКА ГЕРОЙ В КАДРЕ (31.08.2026). Владелец увидел на первом
* экране, что полоса лежит поверх абзаца «Мы называем реальную цену вашей
* квартиры». `.barSpacer` это НЕ лечит и не может: он добавляет высоту в
* КОНЕЦ документа, а `.barRoot` `fixed`, то есть накрывает нижние ~67px
* вьюпорта при ЛЮБОЙ прокрутке, включая `scrollTop = 0`.
*
* Пока герой виден, панель не нужна: прямо в нём стоит та же форма проверки,
* и панель дублирует призыв, закрывая текст. Уходит герой панель появляется.
*
* ПО УМОЛЧАНИЮ ПАНЕЛЬ ВИДИМА, и это важно. Скрытие включает только клиентский
* наблюдатель; если скрипт не выполнился (ошибка, старый браузер, отключённый
* JS), поведение остаётся ровно сегодняшним полоса на месте. Обратный
* порядок (по умолчанию скрыта, показывает скрипт) в тех же условиях убрал бы
* призыв со страницы совсем, то есть чинил бы вид ценой работы.
*
* Наблюдатель смотрит на `#check` секцию героя (`HeroV3`). Если её на
* странице нет (компонент вставили в другую композицию), наблюдать нечего и
* панель просто остаётся видимой: снова сегодняшнее поведение, а не пустой
* экран.
*/
import { useEffect, useState } from "react";
import styles from "../../landing-v3.module.css";
import { PublicLink } from "../PublicLink";
/** Секция героя: пока она в кадре, панель молчит. */
const HERO_ID = "check";
export function StickyCtaV3() {
const [heroVisible, setHeroVisible] = useState(false);
useEffect(() => {
const hero = document.getElementById(HERO_ID);
if (!hero || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(
([entry]) => setHeroVisible(entry.isIntersecting),
// Панель высотой ~67px снизу: считаем героя «в кадре», пока он заходит
// хотя бы на эту полосу — иначе она успевала бы мигнуть на его хвосте.
{ rootMargin: "0px 0px -68px 0px" },
);
io.observe(hero);
return () => io.disconnect();
}, []);
return (
<>
<div className={styles.barSpacer} aria-hidden="true" />
<div className={styles.barRoot}>
<div
className={`${styles.barRoot} ${heroVisible ? styles.barHidden : ""}`}
// Пока панель убрана, её не должно быть и для скринридера с
// клавиатуры: иначе Tab уходит в кнопку, которой на экране нет.
aria-hidden={heroVisible || undefined}
inert={heroVisible || undefined}
>
<p className={styles.barText}>
Узнайте цену своей квартиры и что происходит с похожими рядом
</p>

View file

@ -365,6 +365,24 @@
страницы при докрутке до конца (реквизиты в подвале, последний вопрос
FAQ). Высота одна на все ширины: панель везде в одну строку на узком
экране за счёт того, что текст скрыт (см. `.barText`), а не переносится. */
/* Панель убрана, пока герой в кадре (см. докстринг StickyCtaV3).
Убирается ТРАНСФОРМОМ, а не display:none: transform идёт на композиторе и
не вызывает перекладку. Страница и так дорога в отрисовке на мобильном
(1824 мс в Style & Layout), и лечение перекрытия не должно стоить ещё
одного пересчёта макета при каждом пересечении границы героя. */
.barHidden {
transform: translateY(100%);
opacity: 0;
pointer-events: none;
transition: transform 180ms ease, opacity 180ms ease;
}
@media (prefers-reduced-motion: reduce) {
.barHidden {
transition: none;
}
}
.barSpacer {
height: 68px;
}