gendesign/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx
lekss361 3a2b4ba825
All checks were successful
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 / deploy (push) Successful in 43s
Deploy Trade-In / build-frontend (push) Successful in 1m52s
feat(tradein/v2): кликабельные строки + параметры в оверлее «Предыдущие оценки» (#2424)
2026-07-04 13:49:18 +00:00

331 lines
12 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;
// #2420 — «ПРЕДЫДУЩИЕ ОЦЕНКИ» row click: navigate to the full report for
// that historical estimate. Optional (threaded straight to CacheView's own
// optional onSelectRow — unwired usage leaves rows inert).
onSelectEstimate?: (id: string) => void;
}
// Panel geometry (relative to the artboard). Kept as a constant so the click
// backdrop can align its top edge to the panel — the backdrop starts BELOW the
// TopNav so the top tabs stay clickable while an overlay is open.
const PANEL_TOP = 80;
export default function SectionOverlay({
active,
onClose,
onNavigate,
history,
sources,
analytics,
cache,
onSelectEstimate,
}: SectionOverlayProps) {
// 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 bodyRef = useRef<HTMLDivElement>(null);
const lastFocused = useRef<Element | null>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
// M7 — the overlay body is ONE scroller shared by every section view; switching
// sections (nav within the overlay) used to inherit the previous section's
// scroll offset, dropping the user into the middle of the new view. Reset to the
// top whenever the active section changes.
useEffect(() => {
if (bodyRef.current) bodyRef.current.scrollTop = 0;
}, [active]);
useEffect(() => {
const dialog = dialogRef.current;
// Remember the trigger, then move focus into the dialog.
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[] {
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]"),
);
}
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 at all — keep focus pinned on the dialog itself.
e.preventDefault();
dialog.focus();
return;
}
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 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 || !inRoot) {
e.preventDefault();
last.focus();
}
} else if (activeEl === last || !inRoot) {
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 (
<>
{/* Click-outside backdrop. A transparent layer covering the artboard BELOW
the TopNav (top: PANEL_TOP) — clicking the margin area around the panel
closes the overlay (in addition to Esc). It is a SIBLING of the dialog
(not an ancestor) and sits one z-index below it, so clicks inside the
panel / on its buttons never reach this handler (they don't bubble to a
sibling) and therefore never close the overlay. Starting below the nav
keeps the top tabs clickable. aria-hidden + no tabIndex → invisible to
AT and keyboard; Esc remains the keyboard close path. */}
<div
aria-hidden="true"
onClick={onClose}
style={{
position: "absolute",
left: 0,
right: 0,
top: PANEL_TOP,
bottom: 0,
zIndex: 29,
}}
/>
<div
ref={dialogRef}
role="dialog"
// 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={{
position: "absolute",
left: 28,
right: 28,
top: PANEL_TOP,
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 {
transition: all .15s;
background: ${tokens.accentDeep};
box-shadow: 0 3px 10px rgba(46,139,255,.28);
}
.tiv2-so-back:hover {
box-shadow: 0 6px 16px rgba(46,139,255,.45);
transform: translateY(-1px);
}
.tiv2-so-back:active {
transform: translateY(0);
box-shadow: 0 2px 6px rgba(46,139,255,.3);
}
`}</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,
// 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>
{/* 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,
color: tokens.ink2,
}}
>
{overlayTitles[active]}
</h2>
</div>
<button
type="button"
onClick={onClose}
className="tiv2-so-back"
aria-label="Вернуться к оценке"
style={{
display: "flex",
alignItems: "center",
gap: 8,
cursor: "pointer",
border: `1px solid ${tokens.accentDeep}`,
borderRadius: 6,
padding: "8px 15px",
// background + shadow live in .tiv2-so-back so :hover can override
// them (an inline background would beat the hover rule on
// specificity). Solid accentDeep keeps white text ≥4.5:1 (AA).
fontFamily: "inherit",
}}
>
<span
style={{ fontSize: 15, color: tokens.onAccent, lineHeight: 1 }}
>
</span>
<span
style={{
fontSize: 11,
fontWeight: 600,
letterSpacing: 1.5,
color: tokens.onAccent,
}}
>
К ОЦЕНКЕ
</span>
</button>
</div>
{/* overlay body */}
<div
ref={bodyRef}
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} onSelectRow={onSelectEstimate} />
)}
</div>
</div>
</>
);
}