Compare commits
No commits in common. "1b7d10179f93019c9ca94c646fe3fd5e95cf70cf" and "86e695625795dab8652eb62f94d20c7e9f1b1d6a" have entirely different histories.
1b7d10179f
...
86e6956257
3 changed files with 1 additions and 160 deletions
|
|
@ -1,92 +0,0 @@
|
||||||
/**
|
|
||||||
* Липкая панель не должна лежать поверх первого экрана.
|
|
||||||
*
|
|
||||||
* Владелец увидел на проде, что полоса накрывает абзац «Мы называем реальную
|
|
||||||
* цену вашей квартиры». `.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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* StickyCtaV3 — липкая нижняя CTA-панель (макет ~ строки 518-521).
|
* StickyCtaV3 — липкая нижняя CTA-панель (макет ~ строки 518-521).
|
||||||
*
|
*
|
||||||
|
|
@ -34,64 +32,17 @@
|
||||||
* чаще всего. В тексте панели по той же причине нет и «срока продажи»:
|
* чаще всего. В тексте панели по той же причине нет и «срока продажи»:
|
||||||
* платный отчёт отдаёт медиану экспозиции похожих объявлений, а не прогноз
|
* платный отчёт отдаёт медиану экспозиции похожих объявлений, а не прогноз
|
||||||
* срока (`_estimate_days_on_market`, см. HeroV3).
|
* срока (`_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 styles from "../../landing-v3.module.css";
|
||||||
|
|
||||||
import { PublicLink } from "../PublicLink";
|
import { PublicLink } from "../PublicLink";
|
||||||
|
|
||||||
/** Секция героя: пока она в кадре, панель молчит. */
|
|
||||||
const HERO_ID = "check";
|
|
||||||
|
|
||||||
export function StickyCtaV3() {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.barSpacer} aria-hidden="true" />
|
<div className={styles.barSpacer} aria-hidden="true" />
|
||||||
<div
|
<div className={styles.barRoot}>
|
||||||
className={`${styles.barRoot} ${heroVisible ? styles.barHidden : ""}`}
|
|
||||||
// Пока панель убрана, её не должно быть и для скринридера с
|
|
||||||
// клавиатуры: иначе Tab уходит в кнопку, которой на экране нет.
|
|
||||||
aria-hidden={heroVisible || undefined}
|
|
||||||
inert={heroVisible || undefined}
|
|
||||||
>
|
|
||||||
<p className={styles.barText}>
|
<p className={styles.barText}>
|
||||||
Узнайте цену своей квартиры и что происходит с похожими рядом
|
Узнайте цену своей квартиры и что происходит с похожими рядом
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -365,24 +365,6 @@
|
||||||
страницы при докрутке до конца (реквизиты в подвале, последний вопрос
|
страницы при докрутке до конца (реквизиты в подвале, последний вопрос
|
||||||
FAQ). Высота одна на все ширины: панель везде в одну строку — на узком
|
FAQ). Высота одна на все ширины: панель везде в одну строку — на узком
|
||||||
экране за счёт того, что текст скрыт (см. `.barText`), а не переносится. */
|
экране за счёт того, что текст скрыт (см. `.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 {
|
.barSpacer {
|
||||||
height: 68px;
|
height: 68px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue