Merge pull request 'fix(tradein/v2/a11y): немодальный SectionOverlay + контраст токенов + headings — a11y round 3 (#2264)' (#2279) from fix/tradein-v2-a11y-round3 into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 3m27s
Deploy Trade-In / deploy (push) Has been cancelled

This commit is contained in:
lekss361 2026-07-03 07:47:09 +00:00
commit 703f6f60c0
8 changed files with 224 additions and 56 deletions

View file

@ -10,7 +10,7 @@
// a pending or errored street-deals / analytics call degrades its section to a
// fallback inside the mapper instead of blanking the page.
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
@ -247,8 +247,10 @@ function PlaceholderPanel({
fontFamily: tokens.font.sans,
}}
>
<div
{/* Panel heading — real <h2> (preflight not loaded → reset UA margin). */}
<h2
style={{
margin: 0,
fontSize: 13,
fontWeight: 600,
letterSpacing: 2,
@ -256,7 +258,7 @@ function PlaceholderPanel({
}}
>
{title}
</div>
</h2>
<div
style={{
fontSize: 12,
@ -351,8 +353,10 @@ function SummaryPlaceholder() {
fontFamily: tokens.font.sans,
}}
>
<div
{/* Panel heading — real <h2> (preflight not loaded → reset UA margin). */}
<h2
style={{
margin: 0,
fontSize: 12,
fontWeight: 600,
letterSpacing: 1.5,
@ -360,7 +364,7 @@ function SummaryPlaceholder() {
}}
>
СВОДКА ОБЪЕКТА
</div>
</h2>
<div style={{ fontSize: 11, color: tokens.muted, lineHeight: 1.5 }}>
Появится после оценки квартиры.
</div>
@ -382,6 +386,13 @@ export default function TradeInV2Page() {
const [nav, setNav] = useState(0);
const [drawerOpen, setDrawerOpen] = useState(false);
// #2264 C7: after a fresh estimate lands we move focus onto the result region
// (a labelled, tabIndex={-1} container in ResultPanel) so keyboard/SR users
// don't get dumped back on <body>. `focusedEstimateRef` guards against
// re-focusing on unrelated re-renders / sub-hook updates.
const resultRegionRef = useRef<HTMLDivElement>(null);
const focusedEstimateRef = useRef<string | null>(null);
// Locally-held just-computed estimate (so we render instantly after submit
// without a round-trip through the restore query).
const [freshResult, setFreshResult] = useState<AggregatedEstimate | null>(
@ -498,6 +509,23 @@ export default function TradeInV2Page() {
[estimate, analyticsData, streetDealsData],
);
// Move focus to the result region once a user-submitted estimate is rendered.
// Gated on `freshResult` (a submit, not a restore-by-id cold load, which must
// not steal focus) and on the result actually being on screen (!loading +
// resultPanelData). Fires once per distinct estimate id.
useEffect(() => {
if (
freshResult &&
!loading &&
hasEstimate &&
resultPanelData &&
focusedEstimateRef.current !== freshResult.estimate_id
) {
focusedEstimateRef.current = freshResult.estimate_id;
resultRegionRef.current?.focus();
}
}, [freshResult, loading, hasEstimate, resultPanelData]);
// ── Overlay (section) mapped data. Computed unconditionally: the mappers
// accept nulls and degrade per-section, so an overlay opened before/without
// an estimate shows an honest empty shell — never the design fixtures as if
@ -604,7 +632,13 @@ export default function TradeInV2Page() {
/>
);
} else if (estimate && !insufficient && resultPanelData) {
middleContent = <ResultPanel data={resultPanelData} onNavigate={setNav} />;
middleContent = (
<ResultPanel
data={resultPanelData}
onNavigate={setNav}
regionRef={resultRegionRef}
/>
);
} else if (estimate && insufficient) {
middleContent = <InsufficientPanel />;
} else {
@ -655,6 +689,25 @@ export default function TradeInV2Page() {
>
<style>{HELPER_STYLES}</style>
{/* #2264 C5: the page's single visible "title" is the SVG logo in TopNav,
so give the document a real, visually-hidden <h1> (page-has-heading-one
+ a top of the heading order for the overlay/panel <h2>s). */}
<h1
style={{
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clip: "rect(0 0 0 0)",
whiteSpace: "nowrap",
border: 0,
}}
>
Мера оценка квартиры
</h1>
{/* Polite, visually-hidden status announcer for SR users. */}
<div
aria-live="polite"

View file

@ -76,7 +76,7 @@ export default function HeroBar({
const [imgFailed, setImgFailed] = useState(false);
const pdfBtnInner = (
<>
<svg width="18" height="20" viewBox="0 0 18 20" fill="none">
<svg width="18" height="20" viewBox="0 0 18 20" fill="none" aria-hidden="true">
<path d="M2 1h9l5 5v13H2z" stroke="#2e8bff" strokeWidth="1.2" />
<path d="M11 1v5h5" stroke="#2e8bff" strokeWidth="1.2" />
<line x1="5" y1="11" x2="13" y2="11" stroke="#6f8195" />
@ -367,8 +367,21 @@ export default function HeroBar({
style={{ height: 1, background: tokens.lineSoft, margin: "8px 0" }}
/>
<div
// role=button + tabIndex + Enter/Space (#2264 C6): keyboard-operable
// without switching the element to a <button> (which, with preflight
// off, would need a UA-chrome reset and risk the row's exact box
// geometry / negative-margin hover bleed). Opens the methodology drawer.
role="button"
tabIndex={0}
className="hero-coef-row"
onClick={onOpenInfo}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenInfo();
}
}}
aria-label="Пояснение к расчёту коэффициента локации"
style={{
display: "flex",
alignItems: "center",
@ -431,6 +444,7 @@ export default function HeroBar({
color: tokens.accent,
fontWeight: 600,
}}
aria-hidden="true"
>
?
</span>
@ -451,7 +465,7 @@ export default function HeroBar({
color: tokens.accent,
}}
>
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true">
<circle cx="11" cy="11" r="10" stroke="#2e8bff" strokeWidth="1" />
<path d="M11 3 L13 11 L11 9 L9 11 Z" fill="#2e8bff" />
</svg>

View file

@ -95,9 +95,18 @@ export function ObjectSummary({
>
03
</span>
<span style={{ fontSize: 12, fontWeight: 600, letterSpacing: 1.5 }}>
{/* Panel heading real <h2> (preflight not loaded reset UA margin
inline; visuals unchanged). */}
<h2
style={{
margin: 0,
fontSize: 12,
fontWeight: 600,
letterSpacing: 1.5,
}}
>
СВОДКА ОБЪЕКТА
</span>
</h2>
</div>
<span
style={{
@ -151,6 +160,7 @@ export function ObjectSummary({
viewBox="0 0 15 18"
fill="none"
style={{ marginTop: 1 }}
aria-hidden="true"
>
<path
d="M7.5 17C7.5 17 14 11 14 6.5A6.5 6.5 0 1 0 1 6.5C1 11 7.5 17 7.5 17Z"
@ -167,7 +177,7 @@ export function ObjectSummary({
{data.object.city}
</div>
</div>
<svg width="13" height="13" viewBox="0 0 13 13" fill="none">
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true">
<path
d="M5 1h7v7M12 1L6 7M9 8v4H1V4h4"
stroke={tokens.muted2}
@ -276,7 +286,7 @@ export function ObjectSummary({
alignItems: "center",
}}
>
<svg width="50" height="50" viewBox="0 0 50 50" fill="none">
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" aria-hidden="true">
<circle
cx="25"
cy="25"

View file

@ -40,6 +40,28 @@ import type {
type DdKey = "rooms" | "houseType" | "repair" | "radius" | null;
// Russian plural picker (one / few / many) for SR announcements.
function pluralRu(n: number, one: string, few: string, many: string): string {
const m10 = n % 10;
const m100 = n % 100;
if (m10 === 1 && m100 !== 11) return one;
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return few;
return many;
}
// Visually-hidden style for the polite suggestion-count live region.
const srOnly: CSSProperties = {
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clip: "rect(0 0 0 0)",
whiteSpace: "nowrap",
border: 0,
};
interface DdProps {
open: boolean;
onToggle: () => void;
@ -286,7 +308,7 @@ const styles = `
.pp-dd-trigger-disabled{border:1px dashed ${tokens.line}}
.pp-dd-opt{transition:background .15s}
.pp-dd-opt:hover{background:rgba(46,139,255,.1)}
.pp-input:focus-visible{outline:none;border-color:${tokens.accent};box-shadow:0 0 0 3px rgba(46,139,255,.18)}
.pp-input:focus-visible{outline:none;border-color:${tokens.accentDeep};box-shadow:0 0 0 2px ${tokens.accentDeep}}
.pp-eval-btn{background:linear-gradient(90deg,${tokens.accentDeep},${tokens.accent});box-shadow:0 2px 10px rgba(46,139,255,.25);transition:all .18s}
.pp-eval-btn:hover{background:${tokens.accentDeep};box-shadow:0 6px 22px rgba(46,139,255,.4)}
.pp-eval-btn:active{transform:translateY(1px)}
@ -765,6 +787,14 @@ export default function ParamsPanel({
const addressPopupOpen = suggestOpen && addressQuery.trim().length >= 3;
const addressListboxOpen = addressPopupOpen && addressList.length > 0;
// #2264 C9: a polite announcement of the suggestion COUNT so SR users know the
// list changed under the combobox (role="listbox" is otherwise silent on
// update). The loading / empty states are already announced by the visible
// role="status" note below, so this region carries only the count.
const addressAnnounce = addressListboxOpen
? `${addressList.length} ${pluralRu(addressList.length, "подсказка", "подсказки", "подсказок")}`
: "";
return (
<div
style={{
@ -856,6 +886,7 @@ export default function ParamsPanel({
<svg
viewBox="0 0 440 210"
preserveAspectRatio="xMidYMid slice"
aria-hidden="true"
style={{
position: "absolute",
inset: 0,
@ -1201,6 +1232,7 @@ export default function ParamsPanel({
? addressOptId(addressActive)
: undefined
}
aria-required="true"
aria-invalid={fieldErrors.address ? true : undefined}
aria-describedby={
fieldErrors.address ? "pp-address-err" : undefined
@ -1222,7 +1254,12 @@ export default function ParamsPanel({
: addressField
}
/>
{/* Polite announcement of the suggestion count (#2264 C9). */}
<div aria-live="polite" role="status" style={srOnly}>
{addressAnnounce}
</div>
<svg
aria-hidden="true"
style={{
position: "absolute",
right: 12,
@ -1308,6 +1345,7 @@ export default function ParamsPanel({
type="text"
inputMode="decimal"
value={area}
aria-required="true"
aria-invalid={fieldErrors.area ? true : undefined}
aria-describedby={fieldErrors.area ? "pp-area-err" : undefined}
onChange={(e) => {

View file

@ -1,6 +1,7 @@
"use client";
import { Fragment } from "react";
import type { Ref } from "react";
import { tokens } from "./tokens";
import {
ranges,
@ -47,6 +48,11 @@ const RESULT_FIXTURE: ResultPanelData = {
interface ResultPanelProps {
data?: ResultPanelData;
onNavigate: (i: number) => void;
// #2264 C7: after a successful estimate the page moves focus here (a labelled,
// programmatically-focusable region) so keyboard/SR users land on the result
// instead of the <body>. tabIndex={-1} makes it focus()-able without adding a
// Tab stop.
regionRef?: Ref<HTMLDivElement>;
}
// Mini-histogram column tint: peak column = accent, its neighbours = mid, rest = lo.
@ -85,10 +91,21 @@ function sourceName(name: string) {
export default function ResultPanel({
data = RESULT_FIXTURE,
onNavigate,
regionRef,
}: ResultPanelProps) {
return (
<div
style={{ display: "flex", flexDirection: "column", gap: 14, minWidth: 0 }}
ref={regionRef}
role="region"
aria-labelledby="v2-result-heading"
tabIndex={-1}
style={{
display: "flex",
flexDirection: "column",
gap: 14,
minWidth: 0,
outline: "none",
}}
>
<style>{`
.rp-more { transition: opacity 120ms ease; }
@ -107,9 +124,19 @@ export default function ResultPanel({
<span style={{ fontFamily: font.mono, fontSize: 15, color: accent }}>
02
</span>
<span style={{ fontSize: 13, fontWeight: 600, letterSpacing: 2 }}>
{/* Panel heading real <h2> + the region's accessible name
(aria-labelledby). Preflight not loaded reset UA margin inline. */}
<h2
id="v2-result-heading"
style={{
margin: 0,
fontSize: 13,
fontWeight: 600,
letterSpacing: 2,
}}
>
РЕЗУЛЬТАТ ОЦЕНКИ
</span>
</h2>
</div>
<div
style={{
@ -371,7 +398,7 @@ export default function ResultPanel({
height: 58,
}}
>
<svg width="58" height="58" viewBox="0 0 58 58">
<svg width="58" height="58" viewBox="0 0 58 58" aria-hidden="true">
<circle
cx="29"
cy="29"

View file

@ -44,11 +44,11 @@ export default function SectionOverlay({
analytics,
cache,
}: SectionOverlayProps) {
// Modal dialog plumbing. `dialogRef` is the dialog root, `lastFocused` keeps
// the element that had focus before the overlay opened so we can restore it on
// close, and `onCloseRef` holds the latest onClose so the mount-only effect
// (deps []) never re-runs / re-traps focus on parent re-renders even though
// page.tsx passes a fresh arrow each render.
// Non-modal dialog plumbing. `dialogRef` is the dialog root, `lastFocused`
// keeps the element that had focus before the overlay opened so we can restore
// it on close, and `onCloseRef` holds the latest onClose so the mount-only
// effect (deps []) never re-runs / re-traps focus on parent re-renders even
// though page.tsx passes a fresh arrow each render.
const dialogRef = useRef<HTMLDivElement>(null);
const lastFocused = useRef<Element | null>(null);
const onCloseRef = useRef(onClose);
@ -60,12 +60,23 @@ export default function SectionOverlay({
lastFocused.current = document.activeElement;
dialog?.focus();
// This overlay is an HONEST NON-MODAL fullscreen view (no aria-modal): the
// live top nav is a sibling that is deliberately NOT inerted, so a keyboard
// user can switch sections from within the overlay. The Tab cycle therefore
// spans [live nav tabs … + overlay content] in DOM order — we query the whole
// artboard (the dialog's parent) and drop anything inside an inert subtree
// (the dashboard body under the overlay). This keeps focus out of the hidden
// <main>/<footer> while letting it flow between the nav and the overlay.
const FOCUSABLE_SEL =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
function getFocusable(): HTMLElement[] {
if (!dialog) return [];
const nodes = dialog.querySelectorAll<HTMLElement>(
'button, [href], input, select, [tabindex]:not([tabindex="-1"])',
const root = dialog?.parentElement;
if (!root) return [];
const nodes = root.querySelectorAll<HTMLElement>(FOCUSABLE_SEL);
return Array.from(nodes).filter(
(el) => !el.hasAttribute("disabled") && !el.closest("[inert]"),
);
return Array.from(nodes).filter((el) => !el.hasAttribute("disabled"));
}
function onKeyDown(e: KeyboardEvent) {
@ -78,23 +89,26 @@ export default function SectionOverlay({
const focusable = getFocusable();
if (focusable.length === 0) {
// Nothing tabbable inside — keep focus pinned on the dialog itself.
// Nothing tabbable at all — keep focus pinned on the dialog itself.
e.preventDefault();
dialog.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const root = dialog.parentElement;
const first = focusable[0]; // first live nav tab
const last = focusable[focusable.length - 1]; // last overlay control
const activeEl = document.activeElement;
const inside = dialog.contains(activeEl);
const inRoot = root ? root.contains(activeEl) : false;
// Only wrap at the two extremes; every middle step (incl. nav ↔ overlay)
// uses the browser's native order, which already skips the inert body.
if (e.shiftKey) {
if (activeEl === first || activeEl === dialog || !inside) {
if (activeEl === first || !inRoot) {
e.preventDefault();
last.focus();
}
} else if (activeEl === last || !inside) {
} else if (activeEl === last || !inRoot) {
e.preventDefault();
first.focus();
}
@ -135,7 +149,9 @@ export default function SectionOverlay({
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
// No aria-modal: the top nav stays live/reachable behind this overlay
// (only <main>/<footer> are inerted), so claiming full modality would be
// dishonest to SR users (#2264 C1). role=dialog + label + focus mgmt stay.
aria-labelledby="v2-overlay-title"
tabIndex={-1}
style={{
@ -215,14 +231,21 @@ export default function SectionOverlay({
style={{
fontFamily: tokens.font.mono,
fontSize: 15,
color: tokens.accent,
// accentDeep (not accent): the section number is 15px mono text,
// and accent #2e8bff on the near-white overlay header is only
// ~2.9:1; accentDeep #0d6fd6 clears AA (#2264 C4).
color: tokens.accentDeep,
}}
>
{overlayNums[active]}
</span>
<span
{/* Section heading real <h2> (page h1 is the sr-only «Мера »).
Preflight is not loaded here, so the h2 UA defaults are reset
inline (margin/size/weight) to keep the visuals unchanged. */}
<h2
id="v2-overlay-title"
style={{
margin: 0,
fontSize: 14,
fontWeight: 600,
letterSpacing: 2.5,
@ -230,7 +253,7 @@ export default function SectionOverlay({
}}
>
{overlayTitles[active]}
</span>
</h2>
</div>
<button
type="button"

View file

@ -105,6 +105,7 @@ export default function TopNav({
viewBox="100 440 812 238"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<g transform="translate(0,-20)">
<path
@ -370,7 +371,7 @@ export default function TopNav({
</div>
<div style={menuItemDisabledStyle} aria-disabled="true">
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
<circle
cx="7.5"
cy="5"
@ -388,7 +389,7 @@ export default function TopNav({
</div>
<div className="tnav-menuitem" style={menuItemStyle}>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
<path
d="M3 1.5h6l3 3v9H3z"
stroke={tokens.muted}
@ -402,7 +403,9 @@ export default function TopNav({
marginLeft: "auto",
fontFamily: tokens.font.mono,
fontSize: "10px",
color: tokens.accent,
// accentDeep (not accent): 10px counter text; accent #2e8bff
// on the white menu is ~2.9:1, accentDeep clears AA (#2264 C4).
color: tokens.accentDeep,
}}
>
{reports}
@ -410,7 +413,7 @@ export default function TopNav({
</div>
<div style={menuItemDisabledStyle} aria-disabled="true">
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
<circle
cx="7.5"
cy="7.5"
@ -428,7 +431,7 @@ export default function TopNav({
</div>
<div style={menuItemDisabledStyle} aria-disabled="true">
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
<circle
cx="7.5"
cy="7.5"
@ -468,7 +471,7 @@ export default function TopNav({
textAlign: "left",
}}
>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
<path
d="M9 1.5H2.5v12H9M6 7.5h7M10.5 5l2.5 2.5L10.5 10"
stroke="#cd6868"

View file

@ -24,18 +24,18 @@ export const tokens = {
body2: "#46586c",
// Muted text (more prominent → quieter). Darkened to clear WCAG AA (≥4.5:1)
// against the worst-case (darkest) canvas stop #e6eef7 — the artboard bg is a
// light gradient #f4f8fc→#e6eef7 plus a faint blueprint grid, so micro-labels
// (911px, further shrunk ~0.75× by scale-fit) were failing AA badly
// (audit #2081 C1: muted2 2.5:1, muted4 1.7:1). Ratios below are vs #e6eef7;
// they are higher over glass surfaces and the lighter gradient stops. Primary
// labels should use body2/ink2 (6+:1) — these muted stops are for genuinely
// secondary / hint text only. HSV hue+saturation preserved from the originals.
muted: "#586777", // 4.95:1 — most prominent muted (labels, ₽/м², «КАК РАССЧИТАНО»)
muted2: "#5f6a76", // 4.71:1 — card titles / form field captions
muted3: "#626c77", // 4.56:1 — eyebrows («ИТОГИ ПО РАЗДЕЛАМ»), «нет данных»
muted4: "#656c75", // 4.54:1 — true hints only («опц.»/«скоро»/version)
hint: "#5f6b77", // 4.65:1 — small helper / caption text
// against the true worst-case canvas: #dce6f1 — the darkest point of the
// artboard, where two faint blueprint grid lines (rgba(120,150,180,.045))
// intersect over the darkest gradient stop. Ratios below are vs #dce6f1 with a
// ≥0.1 safety margin (audit #2264 C3); they are higher over glass surfaces and
// the lighter gradient stops. Primary labels should use body2/ink2 (6+:1) —
// these muted stops are for genuinely secondary / hint text only. Hue
// preserved from the originals (lightness-only darkening).
muted: "#586777", // 4.59:1 — most prominent muted (labels, ₽/м², «КАК РАССЧИТАНО»)
muted2: "#5c6672", // 4.62:1 — card titles / form field captions
muted3: "#5d6671", // 4.61:1 — eyebrows («ИТОГИ ПО РАЗДЕЛАМ»), «нет данных»
muted4: "#5f666e", // 4.60:1 — true hints only («опц.»/«скоро»/version)
hint: "#5b6672", // 4.64:1 — small helper / caption text
// Borders & hairlines (strong → soft)
line: "#c2d3e3",
@ -78,8 +78,8 @@ export const tokens = {
// Disabled / no-data source card (02 RESULT · inactive source tile, design lines 366-384)
disabledBorder: "#d8e2ec", // inactive source card edge
disabledValue: "#656b71", // 4.61:1 — "—" no-data value (#2081 C1: was #c2cdd9 1.4:1)
disabledLabel: "#636b73", // 4.62:1 — "нет данных" caption (#2081 C1: was #aab7c5 1.7:1)
disabledValue: "#60666c", // 4.60:1 vs #dce6f1 — "—" no-data value (#2081 C1: was #c2cdd9 1.4:1)
disabledLabel: "#5f666e", // 4.60:1 vs #dce6f1 — "нет данных" caption (#2081 C1: was #aab7c5 1.7:1)
// Accent glow (range-marker dot halo, design lines 303/341)
accentGlow: "rgba(46,139,255,.5)",