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) — заход «с клавиатуры можно завершить оценку».
242 lines
7.2 KiB
TypeScript
242 lines
7.2 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;
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|