fix(tradein/v2): keep top nav active over overlays, stronger «К оценке», backdrop-close
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

Three UX fixes on the /trade-in/v2 overlays (feedback):

1. TopNav stays interactive while a section overlay is open. The a11y
   audit #2081 put inert on the whole content wrap (which contains
   TopNav), so opening an overlay disabled the top tabs. Move inert off
   the wrap onto <main>/<footer> only; the nav is inert just for the
   full-screen LocationDrawer. Users can now switch sections directly
   from an open overlay. role=dialog/aria-modal/Esc/focus-trap kept.

2. «← К ОЦЕНКЕ» made a clear filled primary button (solid accentDeep bg,
   white text ≥4.9:1 AA, shadow, hover lift) instead of the weak ghost
   pill. Uses existing accent tokens only.

3. Backdrop click closes the overlay. Add a transparent sibling layer
   below the panel (z below it, starting under the nav so tabs stay
   clickable); clicks in the margin close it. Clicks inside the panel /
   on its buttons never reach it (sibling, not ancestor) so they don't
   close. Esc + focus restore preserved.
This commit is contained in:
bot-backend 2026-07-02 21:28:47 +03:00
parent 3b9dfdb44e
commit 35a0e29122
2 changed files with 182 additions and 118 deletions

View file

@ -701,7 +701,6 @@ export default function TradeInV2Page() {
{/* CONTENT WRAP */} {/* CONTENT WRAP */}
<div <div
inert={nav !== 0 || drawerOpen ? true : undefined}
style={{ style={{
position: "absolute", position: "absolute",
inset: 14, inset: 14,
@ -710,7 +709,17 @@ export default function TradeInV2Page() {
flexDirection: "column", flexDirection: "column",
}} }}
> >
<nav aria-label="Разделы" style={{ display: "contents" }}> {/* TopNav stays interactive while a section overlay is open so the
user can switch sections directly from the overlay. Only the
LocationDrawer (a full modal over the whole HUD) inerts it. The
dashboard body (<main>/<footer>) is still inerted behind ANY
overlay/drawer preserving the focus + click guard from a11y
audit #2081 for everything except the top tabs. */}
<nav
aria-label="Разделы"
inert={drawerOpen ? true : undefined}
style={{ display: "contents" }}
>
<TopNav <TopNav
active={nav} active={nav}
onNavigate={setNav} onNavigate={setNav}
@ -719,7 +728,10 @@ export default function TradeInV2Page() {
onLogout={logout} onLogout={logout}
/> />
</nav> </nav>
<main style={{ display: "contents" }}> <main
inert={nav !== 0 || drawerOpen ? true : undefined}
style={{ display: "contents" }}
>
<HeroBar <HeroBar
data={{ report, object: objectInfo }} data={{ report, object: objectInfo }}
estimateId={mounted ? currentEstimateId : null} estimateId={mounted ? currentEstimateId : null}
@ -749,7 +761,10 @@ export default function TradeInV2Page() {
</div> </div>
</main> </main>
<footer style={{ display: "contents" }}> <footer
inert={nav !== 0 || drawerOpen ? true : undefined}
style={{ display: "contents" }}
>
<Footer data={report} hasEstimate={hasEstimate} /> <Footer data={report} hasEstimate={hasEstimate} />
</footer> </footer>
</div> </div>

View file

@ -30,6 +30,11 @@ interface SectionOverlayProps {
cache?: CacheData; cache?: CacheData;
} }
// 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({ export default function SectionOverlay({
active, active,
onClose, onClose,
@ -105,138 +110,182 @@ export default function SectionOverlay({
}, []); }, []);
return ( return (
<div <>
ref={dialogRef} {/* Click-outside backdrop. A transparent layer covering the artboard BELOW
role="dialog" the TopNav (top: PANEL_TOP) clicking the margin area around the panel
aria-modal="true" closes the overlay (in addition to Esc). It is a SIBLING of the dialog
aria-labelledby="v2-overlay-title" (not an ancestor) and sits one z-index below it, so clicks inside the
tabIndex={-1} panel / on its buttons never reach this handler (they don't bubble to a
style={{ sibling) and therefore never close the overlay. Starting below the nav
position: "absolute", keeps the top tabs clickable. aria-hidden + no tabIndex invisible to
left: 28, AT and keyboard; Esc remains the keyboard close path. */}
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 <div
aria-hidden="true"
onClick={onClose}
style={{ style={{
position: "absolute", position: "absolute",
left: 12, left: 0,
top: 12, right: 0,
width: 15, top: PANEL_TOP,
height: 15, bottom: 0,
borderLeft: `1.5px solid ${tokens.accent}`, zIndex: 29,
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 <div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="v2-overlay-title"
tabIndex={-1}
style={{ style={{
flex: "0 0 auto", position: "absolute",
left: 28,
right: 28,
top: PANEL_TOP,
bottom: 30,
zIndex: 30,
display: "flex", display: "flex",
alignItems: "center", flexDirection: "column",
justifyContent: "space-between", background:
padding: "16px 24px", "linear-gradient(180deg,rgba(244,248,252,.985),rgba(235,242,250,.985))",
borderBottom: "1px solid #cdd9e6", 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,
}} }}
> >
<div style={{ display: "flex", alignItems: "baseline", gap: 11 }}> <style>{`
<span .tiv2-so-back {
style={{ transition: all .15s;
fontFamily: tokens.font.mono, background: ${tokens.accentDeep};
fontSize: 15, box-shadow: 0 3px 10px rgba(46,139,255,.28);
color: tokens.accent, }
}} .tiv2-so-back:hover {
> box-shadow: 0 6px 16px rgba(46,139,255,.45);
{overlayNums[active]} transform: translateY(-1px);
</span> }
<span .tiv2-so-back:active {
id="v2-overlay-title" transform: translateY(0);
style={{ box-shadow: 0 2px 6px rgba(46,139,255,.3);
fontSize: 14, }
fontWeight: 600, `}</style>
letterSpacing: 2.5,
color: tokens.ink2, {/* HUD corner brackets */}
}} <div
>
{overlayTitles[active]}
</span>
</div>
<button
type="button"
onClick={onClose}
className="tiv2-so-back"
style={{ 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", display: "flex",
alignItems: "center", alignItems: "center",
gap: 8, justifyContent: "space-between",
cursor: "pointer", padding: "16px 24px",
border: `1px solid ${tokens.line}`, borderBottom: "1px solid #cdd9e6",
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> <div style={{ display: "flex", alignItems: "baseline", gap: 11 }}>
<span <span
style={{ fontSize: 10, letterSpacing: 1.5, color: tokens.body2 }} 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"
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
</span> style={{ fontSize: 15, color: tokens.onAccent, lineHeight: 1 }}
</button> >
</div>
</span>
<span
style={{
fontSize: 11,
fontWeight: 600,
letterSpacing: 1.5,
color: tokens.onAccent,
}}
>
К ОЦЕНКЕ
</span>
</button>
</div>
{/* overlay body */} {/* overlay body */}
<div <div
style={{ style={{
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
overflowY: "auto", overflowY: "auto",
padding: "22px 24px 26px", padding: "22px 24px 26px",
}} }}
> >
{active === 1 && <HistoryView data={history} />} {active === 1 && <HistoryView data={history} />}
{active === 2 && <SourcesView data={sources} />} {active === 2 && <SourcesView data={sources} />}
{active === 3 && ( {active === 3 && (
<AnalyticsView data={analytics} onNavigate={onNavigate} /> <AnalyticsView data={analytics} onNavigate={onNavigate} />
)} )}
{active === 4 && <CacheView data={cache} />} {active === 4 && <CacheView data={cache} />}
</div>
</div> </div>
</div> </>
); );
} }