fix(tradein/v2): a11y P3a — нативные контролы + модальный диалог + aria-live (#2062)
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) — заход «с клавиатуры можно завершить оценку».
This commit is contained in:
parent
0641183ecd
commit
7066117cb8
5 changed files with 205 additions and 26 deletions
|
|
@ -617,6 +617,19 @@ export default function TradeInV2Page() {
|
|||
rightContent = <SummaryPlaceholder />;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div style={{ width: 1536 * artboardScale, height: 1024 * artboardScale }}>
|
||||
<div
|
||||
|
|
@ -634,6 +647,25 @@ export default function TradeInV2Page() {
|
|||
>
|
||||
<style>{HELPER_STYLES}</style>
|
||||
|
||||
{/* Polite, visually-hidden status announcer for SR users. */}
|
||||
<div
|
||||
aria-live="polite"
|
||||
role="status"
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: "hidden",
|
||||
clip: "rect(0 0 0 0)",
|
||||
whiteSpace: "nowrap",
|
||||
border: 0,
|
||||
}}
|
||||
>
|
||||
{statusMessage}
|
||||
</div>
|
||||
|
||||
{/* OUTER HUD FRAME */}
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -661,6 +693,7 @@ export default function TradeInV2Page() {
|
|||
|
||||
{/* CONTENT WRAP */}
|
||||
<div
|
||||
inert={nav !== 0 || drawerOpen ? true : undefined}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 14,
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export function ObjectSummary({
|
|||
}}
|
||||
>
|
||||
<style>{`
|
||||
.os-row { transition: background .12s; }
|
||||
.os-row { background: none; border: none; transition: background .12s; }
|
||||
.os-row:hover { background: rgba(46,139,255,.07); }
|
||||
`}</style>
|
||||
|
||||
|
|
@ -159,21 +159,26 @@ export function ObjectSummary({
|
|||
{/* Per-section totals (clickable) */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
|
||||
{data.summary.rows.map((row, i) => (
|
||||
<div
|
||||
<button
|
||||
key={row.label}
|
||||
type="button"
|
||||
className="os-row"
|
||||
onClick={() => 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,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
|
|
@ -211,7 +216,7 @@ export function ObjectSummary({
|
|||
</span>
|
||||
<span style={{ color: tokens.accent, fontSize: 11 }}>›</span>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -273,9 +273,11 @@ export default function ResultPanel({
|
|||
/>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className="rp-more"
|
||||
onClick={() => 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",
|
||||
}}
|
||||
>
|
||||
Подробнее <span>→</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className="rp-more"
|
||||
onClick={() => 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",
|
||||
}}
|
||||
>
|
||||
Подробнее <span>→</span>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
|
|
@ -635,9 +650,11 @@ export default function ResultPanel({
|
|||
объект
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className="rp-more"
|
||||
onClick={() => 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",
|
||||
}}
|
||||
>
|
||||
Подробнее <span>→</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* right range — deals */}
|
||||
|
|
@ -756,9 +778,11 @@ export default function ResultPanel({
|
|||
>
|
||||
ИСТОЧНИКИ ДАННЫХ
|
||||
</span>
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
className="rp-more"
|
||||
onClick={() => 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",
|
||||
}}
|
||||
>
|
||||
Подробнее <span>→</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
// 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";
|
||||
|
|
@ -37,8 +39,78 @@ export default function SectionOverlay({
|
|||
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,
|
||||
|
|
@ -59,7 +131,7 @@ export default function SectionOverlay({
|
|||
}}
|
||||
>
|
||||
<style>{`
|
||||
.tiv2-so-back { transition: all .15s; }
|
||||
.tiv2-so-back { color: ${tokens.ink}; transition: all .15s; }
|
||||
.tiv2-so-back:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; }
|
||||
`}</style>
|
||||
|
||||
|
|
@ -111,6 +183,7 @@ export default function SectionOverlay({
|
|||
{overlayNums[active]}
|
||||
</span>
|
||||
<span
|
||||
id="v2-overlay-title"
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
|
|
@ -121,7 +194,8 @@ export default function SectionOverlay({
|
|||
{overlayTitles[active]}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="tiv2-so-back"
|
||||
style={{
|
||||
|
|
@ -133,6 +207,9 @@ export default function SectionOverlay({
|
|||
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>
|
||||
|
|
@ -141,7 +218,7 @@ export default function SectionOverlay({
|
|||
>
|
||||
К ОЦЕНКЕ
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* overlay body */}
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
`}</style>
|
||||
|
||||
|
|
@ -184,11 +186,27 @@ export default function TopNav({
|
|||
}}
|
||||
>
|
||||
{navLabels.map((label, i) => (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
key={label}
|
||||
onClick={() => 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}
|
||||
<div
|
||||
|
|
@ -220,15 +238,23 @@ export default function TopNav({
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* User menu */}
|
||||
<div style={{ position: "relative" }}>
|
||||
<div
|
||||
<div
|
||||
style={{ position: "relative" }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setUserOpen(false);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUserOpen((o) => !o)}
|
||||
className="tnav-userbtn"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={userOpen}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
|
@ -237,6 +263,8 @@ export default function TopNav({
|
|||
padding: "5px 8px 5px 6px",
|
||||
borderRadius: "9px",
|
||||
transition: "background .15s",
|
||||
fontFamily: "inherit",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
|
@ -281,7 +309,7 @@ export default function TopNav({
|
|||
</div>
|
||||
</div>
|
||||
<span style={{ color: tokens.muted2, fontSize: "9px" }}>▾</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{userOpen && (
|
||||
<div
|
||||
|
|
@ -425,13 +453,20 @@ export default function TopNav({
|
|||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUserOpen(false);
|
||||
onLogout?.();
|
||||
}}
|
||||
className="tnav-logout"
|
||||
style={{ ...menuItemStyle, color: tokens.danger }}
|
||||
style={{
|
||||
...menuItemStyle,
|
||||
color: tokens.danger,
|
||||
width: "100%",
|
||||
fontFamily: "inherit",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
|
||||
<path
|
||||
|
|
@ -441,7 +476,7 @@ export default function TopNav({
|
|||
/>
|
||||
</svg>
|
||||
Выйти
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue