fix(tradein/v2/a11y): немодальный SectionOverlay + контраст токенов + headings — a11y round 3 (#2264) #2279
8 changed files with 224 additions and 56 deletions
|
|
@ -10,7 +10,7 @@
|
||||||
// a pending or errored street-deals / analytics call degrades its section to a
|
// a pending or errored street-deals / analytics call degrades its section to a
|
||||||
// fallback inside the mapper instead of blanking the page.
|
// 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 type { CSSProperties, ReactNode } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
@ -247,8 +247,10 @@ function PlaceholderPanel({
|
||||||
fontFamily: tokens.font.sans,
|
fontFamily: tokens.font.sans,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
{/* Panel heading — real <h2> (preflight not loaded → reset UA margin). */}
|
||||||
|
<h2
|
||||||
style={{
|
style={{
|
||||||
|
margin: 0,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
letterSpacing: 2,
|
letterSpacing: 2,
|
||||||
|
|
@ -256,7 +258,7 @@ function PlaceholderPanel({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</h2>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
|
|
@ -351,8 +353,10 @@ function SummaryPlaceholder() {
|
||||||
fontFamily: tokens.font.sans,
|
fontFamily: tokens.font.sans,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
{/* Panel heading — real <h2> (preflight not loaded → reset UA margin). */}
|
||||||
|
<h2
|
||||||
style={{
|
style={{
|
||||||
|
margin: 0,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
letterSpacing: 1.5,
|
letterSpacing: 1.5,
|
||||||
|
|
@ -360,7 +364,7 @@ function SummaryPlaceholder() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
СВОДКА ОБЪЕКТА
|
СВОДКА ОБЪЕКТА
|
||||||
</div>
|
</h2>
|
||||||
<div style={{ fontSize: 11, color: tokens.muted, lineHeight: 1.5 }}>
|
<div style={{ fontSize: 11, color: tokens.muted, lineHeight: 1.5 }}>
|
||||||
Появится после оценки квартиры.
|
Появится после оценки квартиры.
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -382,6 +386,13 @@ export default function TradeInV2Page() {
|
||||||
|
|
||||||
const [nav, setNav] = useState(0);
|
const [nav, setNav] = useState(0);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
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
|
// Locally-held just-computed estimate (so we render instantly after submit
|
||||||
// without a round-trip through the restore query).
|
// without a round-trip through the restore query).
|
||||||
const [freshResult, setFreshResult] = useState<AggregatedEstimate | null>(
|
const [freshResult, setFreshResult] = useState<AggregatedEstimate | null>(
|
||||||
|
|
@ -498,6 +509,23 @@ export default function TradeInV2Page() {
|
||||||
[estimate, analyticsData, streetDealsData],
|
[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
|
// ── Overlay (section) mapped data. Computed unconditionally: the mappers
|
||||||
// accept nulls and degrade per-section, so an overlay opened before/without
|
// 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
|
// 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) {
|
} else if (estimate && !insufficient && resultPanelData) {
|
||||||
middleContent = <ResultPanel data={resultPanelData} onNavigate={setNav} />;
|
middleContent = (
|
||||||
|
<ResultPanel
|
||||||
|
data={resultPanelData}
|
||||||
|
onNavigate={setNav}
|
||||||
|
regionRef={resultRegionRef}
|
||||||
|
/>
|
||||||
|
);
|
||||||
} else if (estimate && insufficient) {
|
} else if (estimate && insufficient) {
|
||||||
middleContent = <InsufficientPanel />;
|
middleContent = <InsufficientPanel />;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -655,6 +689,25 @@ export default function TradeInV2Page() {
|
||||||
>
|
>
|
||||||
<style>{HELPER_STYLES}</style>
|
<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. */}
|
{/* Polite, visually-hidden status announcer for SR users. */}
|
||||||
<div
|
<div
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ export default function HeroBar({
|
||||||
const [imgFailed, setImgFailed] = useState(false);
|
const [imgFailed, setImgFailed] = useState(false);
|
||||||
const pdfBtnInner = (
|
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="M2 1h9l5 5v13H2z" stroke="#2e8bff" strokeWidth="1.2" />
|
||||||
<path d="M11 1v5h5" 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" />
|
<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" }}
|
style={{ height: 1, background: tokens.lineSoft, margin: "8px 0" }}
|
||||||
/>
|
/>
|
||||||
<div
|
<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"
|
className="hero-coef-row"
|
||||||
onClick={onOpenInfo}
|
onClick={onOpenInfo}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onOpenInfo();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label="Пояснение к расчёту коэффициента локации"
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|
@ -431,6 +444,7 @@ export default function HeroBar({
|
||||||
color: tokens.accent,
|
color: tokens.accent,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
?
|
?
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -451,7 +465,7 @@ export default function HeroBar({
|
||||||
color: tokens.accent,
|
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" />
|
<circle cx="11" cy="11" r="10" stroke="#2e8bff" strokeWidth="1" />
|
||||||
<path d="M11 3 L13 11 L11 9 L9 11 Z" fill="#2e8bff" />
|
<path d="M11 3 L13 11 L11 9 L9 11 Z" fill="#2e8bff" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
|
@ -95,9 +95,18 @@ export function ObjectSummary({
|
||||||
>
|
>
|
||||||
03
|
03
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -151,6 +160,7 @@ export function ObjectSummary({
|
||||||
viewBox="0 0 15 18"
|
viewBox="0 0 15 18"
|
||||||
fill="none"
|
fill="none"
|
||||||
style={{ marginTop: 1 }}
|
style={{ marginTop: 1 }}
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
<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"
|
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}
|
{data.object.city}
|
||||||
</div>
|
</div>
|
||||||
</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
|
<path
|
||||||
d="M5 1h7v7M12 1L6 7M9 8v4H1V4h4"
|
d="M5 1h7v7M12 1L6 7M9 8v4H1V4h4"
|
||||||
stroke={tokens.muted2}
|
stroke={tokens.muted2}
|
||||||
|
|
@ -276,7 +286,7 @@ export function ObjectSummary({
|
||||||
alignItems: "center",
|
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
|
<circle
|
||||||
cx="25"
|
cx="25"
|
||||||
cy="25"
|
cy="25"
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,28 @@ import type {
|
||||||
|
|
||||||
type DdKey = "rooms" | "houseType" | "repair" | "radius" | null;
|
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 {
|
interface DdProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
|
|
@ -286,7 +308,7 @@ const styles = `
|
||||||
.pp-dd-trigger-disabled{border:1px dashed ${tokens.line}}
|
.pp-dd-trigger-disabled{border:1px dashed ${tokens.line}}
|
||||||
.pp-dd-opt{transition:background .15s}
|
.pp-dd-opt{transition:background .15s}
|
||||||
.pp-dd-opt:hover{background:rgba(46,139,255,.1)}
|
.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{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:hover{background:${tokens.accentDeep};box-shadow:0 6px 22px rgba(46,139,255,.4)}
|
||||||
.pp-eval-btn:active{transform:translateY(1px)}
|
.pp-eval-btn:active{transform:translateY(1px)}
|
||||||
|
|
@ -765,6 +787,14 @@ export default function ParamsPanel({
|
||||||
const addressPopupOpen = suggestOpen && addressQuery.trim().length >= 3;
|
const addressPopupOpen = suggestOpen && addressQuery.trim().length >= 3;
|
||||||
const addressListboxOpen = addressPopupOpen && addressList.length > 0;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -856,6 +886,7 @@ export default function ParamsPanel({
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 440 210"
|
viewBox="0 0 440 210"
|
||||||
preserveAspectRatio="xMidYMid slice"
|
preserveAspectRatio="xMidYMid slice"
|
||||||
|
aria-hidden="true"
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
|
|
@ -1201,6 +1232,7 @@ export default function ParamsPanel({
|
||||||
? addressOptId(addressActive)
|
? addressOptId(addressActive)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
aria-required="true"
|
||||||
aria-invalid={fieldErrors.address ? true : undefined}
|
aria-invalid={fieldErrors.address ? true : undefined}
|
||||||
aria-describedby={
|
aria-describedby={
|
||||||
fieldErrors.address ? "pp-address-err" : undefined
|
fieldErrors.address ? "pp-address-err" : undefined
|
||||||
|
|
@ -1222,7 +1254,12 @@ export default function ParamsPanel({
|
||||||
: addressField
|
: addressField
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* Polite announcement of the suggestion count (#2264 C9). */}
|
||||||
|
<div aria-live="polite" role="status" style={srOnly}>
|
||||||
|
{addressAnnounce}
|
||||||
|
</div>
|
||||||
<svg
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
right: 12,
|
right: 12,
|
||||||
|
|
@ -1308,6 +1345,7 @@ export default function ParamsPanel({
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={area}
|
value={area}
|
||||||
|
aria-required="true"
|
||||||
aria-invalid={fieldErrors.area ? true : undefined}
|
aria-invalid={fieldErrors.area ? true : undefined}
|
||||||
aria-describedby={fieldErrors.area ? "pp-area-err" : undefined}
|
aria-describedby={fieldErrors.area ? "pp-area-err" : undefined}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
|
import type { Ref } from "react";
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import {
|
import {
|
||||||
ranges,
|
ranges,
|
||||||
|
|
@ -47,6 +48,11 @@ const RESULT_FIXTURE: ResultPanelData = {
|
||||||
interface ResultPanelProps {
|
interface ResultPanelProps {
|
||||||
data?: ResultPanelData;
|
data?: ResultPanelData;
|
||||||
onNavigate: (i: number) => void;
|
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.
|
// Mini-histogram column tint: peak column = accent, its neighbours = mid, rest = lo.
|
||||||
|
|
@ -85,10 +91,21 @@ function sourceName(name: string) {
|
||||||
export default function ResultPanel({
|
export default function ResultPanel({
|
||||||
data = RESULT_FIXTURE,
|
data = RESULT_FIXTURE,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
|
regionRef,
|
||||||
}: ResultPanelProps) {
|
}: ResultPanelProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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>{`
|
<style>{`
|
||||||
.rp-more { transition: opacity 120ms ease; }
|
.rp-more { transition: opacity 120ms ease; }
|
||||||
|
|
@ -107,9 +124,19 @@ export default function ResultPanel({
|
||||||
<span style={{ fontFamily: font.mono, fontSize: 15, color: accent }}>
|
<span style={{ fontFamily: font.mono, fontSize: 15, color: accent }}>
|
||||||
02
|
02
|
||||||
</span>
|
</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>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -371,7 +398,7 @@ export default function ResultPanel({
|
||||||
height: 58,
|
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
|
<circle
|
||||||
cx="29"
|
cx="29"
|
||||||
cy="29"
|
cy="29"
|
||||||
|
|
|
||||||
|
|
@ -44,11 +44,11 @@ export default function SectionOverlay({
|
||||||
analytics,
|
analytics,
|
||||||
cache,
|
cache,
|
||||||
}: SectionOverlayProps) {
|
}: SectionOverlayProps) {
|
||||||
// Modal dialog plumbing. `dialogRef` is the dialog root, `lastFocused` keeps
|
// Non-modal dialog plumbing. `dialogRef` is the dialog root, `lastFocused`
|
||||||
// the element that had focus before the overlay opened so we can restore it on
|
// keeps the element that had focus before the overlay opened so we can restore
|
||||||
// close, and `onCloseRef` holds the latest onClose so the mount-only effect
|
// it on close, and `onCloseRef` holds the latest onClose so the mount-only
|
||||||
// (deps []) never re-runs / re-traps focus on parent re-renders even though
|
// effect (deps []) never re-runs / re-traps focus on parent re-renders even
|
||||||
// page.tsx passes a fresh arrow each render.
|
// though page.tsx passes a fresh arrow each render.
|
||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
const lastFocused = useRef<Element | null>(null);
|
const lastFocused = useRef<Element | null>(null);
|
||||||
const onCloseRef = useRef(onClose);
|
const onCloseRef = useRef(onClose);
|
||||||
|
|
@ -60,12 +60,23 @@ export default function SectionOverlay({
|
||||||
lastFocused.current = document.activeElement;
|
lastFocused.current = document.activeElement;
|
||||||
dialog?.focus();
|
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[] {
|
function getFocusable(): HTMLElement[] {
|
||||||
if (!dialog) return [];
|
const root = dialog?.parentElement;
|
||||||
const nodes = dialog.querySelectorAll<HTMLElement>(
|
if (!root) return [];
|
||||||
'button, [href], input, select, [tabindex]:not([tabindex="-1"])',
|
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) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
|
@ -78,23 +89,26 @@ export default function SectionOverlay({
|
||||||
|
|
||||||
const focusable = getFocusable();
|
const focusable = getFocusable();
|
||||||
if (focusable.length === 0) {
|
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();
|
e.preventDefault();
|
||||||
dialog.focus();
|
dialog.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const first = focusable[0];
|
const root = dialog.parentElement;
|
||||||
const last = focusable[focusable.length - 1];
|
const first = focusable[0]; // first live nav tab
|
||||||
|
const last = focusable[focusable.length - 1]; // last overlay control
|
||||||
const activeEl = document.activeElement;
|
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 (e.shiftKey) {
|
||||||
if (activeEl === first || activeEl === dialog || !inside) {
|
if (activeEl === first || !inRoot) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
last.focus();
|
last.focus();
|
||||||
}
|
}
|
||||||
} else if (activeEl === last || !inside) {
|
} else if (activeEl === last || !inRoot) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
first.focus();
|
first.focus();
|
||||||
}
|
}
|
||||||
|
|
@ -135,7 +149,9 @@ export default function SectionOverlay({
|
||||||
<div
|
<div
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
role="dialog"
|
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"
|
aria-labelledby="v2-overlay-title"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -215,14 +231,21 @@ export default function SectionOverlay({
|
||||||
style={{
|
style={{
|
||||||
fontFamily: tokens.font.mono,
|
fontFamily: tokens.font.mono,
|
||||||
fontSize: 15,
|
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]}
|
{overlayNums[active]}
|
||||||
</span>
|
</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"
|
id="v2-overlay-title"
|
||||||
style={{
|
style={{
|
||||||
|
margin: 0,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
letterSpacing: 2.5,
|
letterSpacing: 2.5,
|
||||||
|
|
@ -230,7 +253,7 @@ export default function SectionOverlay({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{overlayTitles[active]}
|
{overlayTitles[active]}
|
||||||
</span>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ export default function TopNav({
|
||||||
viewBox="100 440 812 238"
|
viewBox="100 440 812 238"
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<g transform="translate(0,-20)">
|
<g transform="translate(0,-20)">
|
||||||
<path
|
<path
|
||||||
|
|
@ -370,7 +371,7 @@ export default function TopNav({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={menuItemDisabledStyle} aria-disabled="true">
|
<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
|
<circle
|
||||||
cx="7.5"
|
cx="7.5"
|
||||||
cy="5"
|
cy="5"
|
||||||
|
|
@ -388,7 +389,7 @@ export default function TopNav({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tnav-menuitem" style={menuItemStyle}>
|
<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
|
<path
|
||||||
d="M3 1.5h6l3 3v9H3z"
|
d="M3 1.5h6l3 3v9H3z"
|
||||||
stroke={tokens.muted}
|
stroke={tokens.muted}
|
||||||
|
|
@ -402,7 +403,9 @@ export default function TopNav({
|
||||||
marginLeft: "auto",
|
marginLeft: "auto",
|
||||||
fontFamily: tokens.font.mono,
|
fontFamily: tokens.font.mono,
|
||||||
fontSize: "10px",
|
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}
|
{reports}
|
||||||
|
|
@ -410,7 +413,7 @@ export default function TopNav({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={menuItemDisabledStyle} aria-disabled="true">
|
<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
|
<circle
|
||||||
cx="7.5"
|
cx="7.5"
|
||||||
cy="7.5"
|
cy="7.5"
|
||||||
|
|
@ -428,7 +431,7 @@ export default function TopNav({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={menuItemDisabledStyle} aria-disabled="true">
|
<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
|
<circle
|
||||||
cx="7.5"
|
cx="7.5"
|
||||||
cy="7.5"
|
cy="7.5"
|
||||||
|
|
@ -468,7 +471,7 @@ export default function TopNav({
|
||||||
textAlign: "left",
|
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
|
<path
|
||||||
d="M9 1.5H2.5v12H9M6 7.5h7M10.5 5l2.5 2.5L10.5 10"
|
d="M9 1.5H2.5v12H9M6 7.5h7M10.5 5l2.5 2.5L10.5 10"
|
||||||
stroke="#cd6868"
|
stroke="#cd6868"
|
||||||
|
|
|
||||||
|
|
@ -24,18 +24,18 @@ export const tokens = {
|
||||||
body2: "#46586c",
|
body2: "#46586c",
|
||||||
|
|
||||||
// Muted text (more prominent → quieter). Darkened to clear WCAG AA (≥4.5:1)
|
// 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
|
// against the true worst-case canvas: #dce6f1 — the darkest point of the
|
||||||
// light gradient #f4f8fc→#e6eef7 plus a faint blueprint grid, so micro-labels
|
// artboard, where two faint blueprint grid lines (rgba(120,150,180,.045))
|
||||||
// (9–11px, further shrunk ~0.75× by scale-fit) were failing AA badly
|
// intersect over the darkest gradient stop. Ratios below are vs #dce6f1 with a
|
||||||
// (audit #2081 C1: muted2 2.5:1, muted4 1.7:1). Ratios below are vs #e6eef7;
|
// ≥0.1 safety margin (audit #2264 C3); they are higher over glass surfaces and
|
||||||
// they are higher over glass surfaces and the lighter gradient stops. Primary
|
// the lighter gradient stops. Primary labels should use body2/ink2 (6+:1) —
|
||||||
// labels should use body2/ink2 (6+:1) — these muted stops are for genuinely
|
// these muted stops are for genuinely secondary / hint text only. Hue
|
||||||
// secondary / hint text only. HSV hue+saturation preserved from the originals.
|
// preserved from the originals (lightness-only darkening).
|
||||||
muted: "#586777", // 4.95:1 — most prominent muted (labels, ₽/м², «КАК РАССЧИТАНО»)
|
muted: "#586777", // 4.59:1 — most prominent muted (labels, ₽/м², «КАК РАССЧИТАНО»)
|
||||||
muted2: "#5f6a76", // 4.71:1 — card titles / form field captions
|
muted2: "#5c6672", // 4.62:1 — card titles / form field captions
|
||||||
muted3: "#626c77", // 4.56:1 — eyebrows («ИТОГИ ПО РАЗДЕЛАМ»), «нет данных»
|
muted3: "#5d6671", // 4.61:1 — eyebrows («ИТОГИ ПО РАЗДЕЛАМ»), «нет данных»
|
||||||
muted4: "#656c75", // 4.54:1 — true hints only («опц.»/«скоро»/version)
|
muted4: "#5f666e", // 4.60:1 — true hints only («опц.»/«скоро»/version)
|
||||||
hint: "#5f6b77", // 4.65:1 — small helper / caption text
|
hint: "#5b6672", // 4.64:1 — small helper / caption text
|
||||||
|
|
||||||
// Borders & hairlines (strong → soft)
|
// Borders & hairlines (strong → soft)
|
||||||
line: "#c2d3e3",
|
line: "#c2d3e3",
|
||||||
|
|
@ -78,8 +78,8 @@ export const tokens = {
|
||||||
|
|
||||||
// Disabled / no-data source card (02 RESULT · inactive source tile, design lines 366-384)
|
// Disabled / no-data source card (02 RESULT · inactive source tile, design lines 366-384)
|
||||||
disabledBorder: "#d8e2ec", // inactive source card edge
|
disabledBorder: "#d8e2ec", // inactive source card edge
|
||||||
disabledValue: "#656b71", // 4.61:1 — "—" no-data value (#2081 C1: was #c2cdd9 1.4:1)
|
disabledValue: "#60666c", // 4.60:1 vs #dce6f1 — "—" no-data value (#2081 C1: was #c2cdd9 1.4:1)
|
||||||
disabledLabel: "#636b73", // 4.62:1 — "нет данных" caption (#2081 C1: was #aab7c5 1.7: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)
|
// Accent glow (range-marker dot halo, design lines 303/341)
|
||||||
accentGlow: "rgba(46,139,255,.5)",
|
accentGlow: "rgba(46,139,255,.5)",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue