gendesign/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx
bot-backend 7066117cb8
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
fix(tradein/v2): a11y P3a — нативные контролы + модальный диалог + aria-live (#2062)
CRITICAL-доступность (часть 1): клавиатура/скринридер. Без визуальных изменений
(chrome-reset кнопок вынесен в CSS-классы, :hover и font-size сохранены).

- Клик-only <div> → нативные <button>: nav-табы (+aria-current), user-меню
  (aria-haspopup/expanded + Esc-close), «Выйти», «Подробнее →» (ResultPanel,
  +aria-label по разделу), строки 03 СВОДКИ (ObjectSummary, +aria-label).
- SectionOverlay → настоящая модалка: role=dialog, aria-modal, aria-labelledby,
  Esc-close, фокус на диалог при открытии, focus-trap (Tab/Shift+Tab),
  возврат фокуса на триггер при закрытии; «← К ОЦЕНКЕ» → <button>.
- page.tsx: aria-live="polite" role=status — «Идёт расчёт… / Оценка готова /
  Недостаточно данных / Ошибка»; фон (content-wrap) inert когда открыт
  оверлей или drawer (overlay/drawer остаются интерактивны).

Остаётся P3b: ParamsPanel-форма (Dd→listbox+клавиши, БАЛКОН→radiogroup,
input <label>/aria) — заход «с клавиатуры можно завершить оценку».
2026-06-28 21:29:19 +03:00

242 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
// Overlay wrapper for the nav-driven section panels of the /trade-in/v2
// "МЕРА Оценка" design. Faithful port of the design overlay container
// (МЕРА Оценка.dc.html lines 443-453 + 615-616): a glass/blur absolute layer
// with HUD corner brackets, a header (section number + title + "← К ОЦЕНКЕ"
// button) and a scrollable body that swaps the active view.
import { useEffect, useRef } from "react";
import { tokens } from "./tokens";
import { overlayNums, overlayTitles } from "./fixtures";
import HistoryView from "./HistoryView";
import SourcesView from "./SourcesView";
import AnalyticsView from "./AnalyticsView";
import { CacheView } from "./CacheView";
import type { CacheData, HistoryData, SourcesData } from "./mappers";
import type { Analytics } from "./types";
interface SectionOverlayProps {
active: number;
onClose: () => void;
onNavigate: (i: number) => void;
// Mapped overlay data, fed by page.tsx (mapHistory / mapSources / mapAnalytics
// / mapCache). Optional so unwired/storybook usage falls back to each view's
// fixture default.
history?: HistoryData;
sources?: SourcesData;
analytics?: Analytics;
cache?: CacheData;
}
export default function SectionOverlay({
active,
onClose,
onNavigate,
history,
sources,
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.
const dialogRef = useRef<HTMLDivElement>(null);
const lastFocused = useRef<Element | null>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
const dialog = dialogRef.current;
// Remember the trigger, then move focus into the dialog.
lastFocused.current = document.activeElement;
dialog?.focus();
function getFocusable(): HTMLElement[] {
if (!dialog) return [];
const nodes = dialog.querySelectorAll<HTMLElement>(
'button, [href], input, select, [tabindex]:not([tabindex="-1"])',
);
return Array.from(nodes).filter((el) => !el.hasAttribute("disabled"));
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
onCloseRef.current();
return;
}
if (e.key !== "Tab" || !dialog) return;
const focusable = getFocusable();
if (focusable.length === 0) {
// Nothing tabbable inside — keep focus pinned on the dialog itself.
e.preventDefault();
dialog.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const activeEl = document.activeElement;
const inside = dialog.contains(activeEl);
if (e.shiftKey) {
if (activeEl === first || activeEl === dialog || !inside) {
e.preventDefault();
last.focus();
}
} else if (activeEl === last || !inside) {
e.preventDefault();
first.focus();
}
}
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
// Return focus to whatever opened the overlay.
(lastFocused.current as HTMLElement | null)?.focus?.();
};
// Mount-only: refs (dialogRef/onCloseRef) are stable, so no extra deps.
}, []);
return (
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="v2-overlay-title"
tabIndex={-1}
style={{
position: "absolute",
left: 28,
right: 28,
top: 80,
bottom: 30,
zIndex: 30,
display: "flex",
flexDirection: "column",
background:
"linear-gradient(180deg,rgba(244,248,252,.985),rgba(235,242,250,.985))",
backdropFilter: "blur(12px)",
border: `1px solid ${tokens.line3}`,
borderRadius: 8,
boxShadow: "0 22px 60px rgba(40,80,130,.18)",
overflow: "hidden",
fontFamily: tokens.font.sans,
}}
>
<style>{`
.tiv2-so-back { color: ${tokens.ink}; transition: all .15s; }
.tiv2-so-back:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; }
`}</style>
{/* HUD corner brackets */}
<div
style={{
position: "absolute",
left: 12,
top: 12,
width: 15,
height: 15,
borderLeft: `1.5px solid ${tokens.accent}`,
borderTop: `1.5px solid ${tokens.accent}`,
pointerEvents: "none",
}}
/>
<div
style={{
position: "absolute",
right: 12,
bottom: 12,
width: 15,
height: 15,
borderRight: `1.5px solid ${tokens.accent}`,
borderBottom: `1.5px solid ${tokens.accent}`,
pointerEvents: "none",
}}
/>
{/* overlay header */}
<div
style={{
flex: "0 0 auto",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "16px 24px",
borderBottom: "1px solid #cdd9e6",
}}
>
<div style={{ display: "flex", alignItems: "baseline", gap: 11 }}>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 15,
color: tokens.accent,
}}
>
{overlayNums[active]}
</span>
<span
id="v2-overlay-title"
style={{
fontSize: 14,
fontWeight: 600,
letterSpacing: 2.5,
color: tokens.ink2,
}}
>
{overlayTitles[active]}
</span>
</div>
<button
type="button"
onClick={onClose}
className="tiv2-so-back"
style={{
display: "flex",
alignItems: "center",
gap: 8,
cursor: "pointer",
border: `1px solid ${tokens.line}`,
borderRadius: 6,
padding: "6px 12px",
background: tokens.surface.w60,
// reset UA button defaults while keeping the original box look;
// base colour lives in .tiv2-so-back so :hover can win
fontFamily: "inherit",
}}
>
<span style={{ fontSize: 13 }}></span>
<span
style={{ fontSize: 10, letterSpacing: 1.5, color: tokens.muted }}
>
К ОЦЕНКЕ
</span>
</button>
</div>
{/* overlay body */}
<div
style={{
flex: 1,
minHeight: 0,
overflowY: "auto",
padding: "22px 24px 26px",
}}
>
{active === 1 && <HistoryView data={history} />}
{active === 2 && <SourcesView data={sources} />}
{active === 3 && (
<AnalyticsView data={analytics} onNavigate={onNavigate} />
)}
{active === 4 && <CacheView data={cache} />}
</div>
</div>
);
}