diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index 706ed177..09fc21fc 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -617,6 +617,19 @@ export default function TradeInV2Page() { rightContent = ; } + // Polite SR announcement reflecting the estimate lifecycle. Same precedence as + // middleContent: loading → error → insufficient → ready → silent. + let statusMessage = ""; + if (loading) { + statusMessage = "Идёт расчёт оценки…"; + } else if (restoreError) { + statusMessage = "Ошибка загрузки отчёта"; + } else if (estimate && insufficient) { + statusMessage = "Недостаточно данных для оценки"; + } else if (estimate && !insufficient) { + statusMessage = "Оценка готова"; + } + return (
+ {/* Polite, visually-hidden status announcer for SR users. */} +
+ {statusMessage} +
+ {/* OUTER HUD FRAME */}
@@ -159,21 +159,26 @@ export function ObjectSummary({ {/* Per-section totals (clickable) */}
{data.summary.rows.map((row, i) => ( -
onNavigate(row.nav)} + aria-label={`${row.label} — подробнее`} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "9px 6px", + cursor: "pointer", + borderRadius: 5, + width: "100%", + fontFamily: "inherit", + textAlign: "left", borderBottom: i < data.summary.rows.length - 1 ? `1px dotted ${tokens.lineGradient}` : undefined, - cursor: "pointer", - borderRadius: 5, }} > -
+ ))}
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx index 227d691c..179d5686 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx @@ -273,9 +273,11 @@ export default function ResultPanel({ /> ))}
-
onNavigate(card.nav)} + aria-label={`Подробнее — ${card.title.join(" ")}`} style={{ display: "flex", alignItems: "center", @@ -285,18 +287,24 @@ export default function ResultPanel({ letterSpacing: 0.3, color: accent, whiteSpace: "nowrap", + background: "none", + border: "none", + padding: 0, + margin: 0, + fontFamily: "inherit", }} > Подробнее -
+
) : ( <> -
onNavigate(card.nav)} + aria-label={`Подробнее — ${card.title.join(" ")}`} style={{ - marginTop: 16, display: "flex", alignItems: "center", justifyContent: "flex-end", @@ -305,10 +313,17 @@ export default function ResultPanel({ fontSize: 9, letterSpacing: 0.3, color: accent, + width: "100%", + background: "none", + border: "none", + padding: 0, + margin: 0, + marginTop: 16, + fontFamily: "inherit", }} > Подробнее -
+
-
onNavigate(3)} + aria-label="Подробнее — цена и срок продажи" style={{ display: "flex", alignItems: "center", @@ -646,11 +663,16 @@ export default function ResultPanel({ fontSize: 9, letterSpacing: 0.3, color: accent, + background: "none", + border: "none", + padding: 0, + margin: 0, marginTop: 2, + fontFamily: "inherit", }} > Подробнее -
+
{/* right range — deals */} @@ -756,9 +778,11 @@ export default function ResultPanel({ > ИСТОЧНИКИ ДАННЫХ - onNavigate(2)} + aria-label="Подробнее — источники данных" style={{ display: "flex", alignItems: "center", @@ -767,10 +791,15 @@ export default function ResultPanel({ fontSize: 9.5, letterSpacing: 0.3, color: accent, + background: "none", + border: "none", + padding: 0, + margin: 0, + fontFamily: "inherit", }} > Подробнее - +
(null); + const lastFocused = useRef(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( + '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 (
@@ -111,6 +183,7 @@ export default function SectionOverlay({ {overlayNums[active]}
-
@@ -141,7 +218,7 @@ export default function SectionOverlay({ > К ОЦЕНКЕ -
+
{/* overlay body */} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx index 8b967fd1..89a9d2ff 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx @@ -80,8 +80,10 @@ export default function TopNav({ .tnav-tab { color: ${tokens.muted}; font-weight: 400; cursor: pointer; transition: color .2s; } .tnav-tab:hover { color: ${tokens.ink}; } .tnav-tab--active { color: ${tokens.ink}; font-weight: 600; } + .tnav-userbtn { background: none; border: none; } .tnav-userbtn:hover { background: rgba(46,139,255,.07); } .tnav-menuitem:hover { background: rgba(46,139,255,.09); } + .tnav-logout { background: none; border: none; } .tnav-logout:hover { background: rgba(205,104,104,.1); } `} @@ -184,11 +186,27 @@ export default function TopNav({ }} > {navLabels.map((label, i) => ( -
onNavigate(i)} + aria-current={active === i ? "page" : undefined} className={`tnav-tab${active === i ? " tnav-tab--active" : ""}`} - style={{ position: "relative", whiteSpace: "nowrap" }} + style={{ + position: "relative", + whiteSpace: "nowrap", + background: "none", + border: "none", + padding: 0, + margin: 0, + // NB: family/size/letterSpacing inherit for chrome reset, but do + // NOT set `font`/`color` shorthand — the .tnav-tab(--active)/:hover + // CSS must keep driving tab colour + weight. + fontFamily: "inherit", + fontSize: "inherit", + letterSpacing: "inherit", + cursor: "pointer", + }} > {label}
)} -
+ ))}
{/* User menu */} -
-
{ + if (e.key === "Escape") setUserOpen(false); + }} + > +
+ {userOpen && (
-
{ setUserOpen(false); onLogout?.(); }} className="tnav-logout" - style={{ ...menuItemStyle, color: tokens.danger }} + style={{ + ...menuItemStyle, + color: tokens.danger, + width: "100%", + fontFamily: "inherit", + textAlign: "left", + }} > Выйти -
+
)}