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
331 lines
12 KiB
TypeScript
331 lines
12 KiB
TypeScript
"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>
|
||
</>
|
||
);
|
||
}
|