Merge pull request 'fix(tradein/v2): a11y P3b — клавиатурная форма 01 (#2062)' (#2078) from fix/tradein-v2-a11y-form into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m34s
Deploy Trade-In / build-frontend (push) Successful in 2m19s
Deploy Trade-In / build-backend (push) Successful in 44s
Deploy Trade-In / deploy (push) Successful in 1m31s

This commit is contained in:
lekss361 2026-06-28 18:58:34 +00:00
commit 565d5c906b

View file

@ -11,7 +11,16 @@
// the CRM dropdown still has no backend → kept visually but disabled. Hover/active
// + @keyframes live in a pp-prefixed local <style>.
import { useRef, useState, type CSSProperties } from "react";
import {
useEffect,
useId,
useRef,
useState,
type CSSProperties,
type Dispatch,
type KeyboardEvent as ReactKeyboardEvent,
type SetStateAction,
} from "react";
import { tokens } from "./tokens";
import { dropdownOptions } from "./fixtures";
import {
@ -47,6 +56,69 @@ interface DdProps {
triggerColor?: string;
caretColor?: string;
disabled?: boolean;
ariaLabel?: string;
}
// Shared combobox keydown logic for the HUD dropdowns (the <Dd> widget + the
// inline РАДИУС copy). aria-activedescendant pattern: focus STAYS on the trigger
// button; arrows move a virtual `activeIdx` highlight, Enter/Space commit it,
// Escape/Tab close. Navigation keys preventDefault to stop page scroll.
function comboKeyDown(
e: ReactKeyboardEvent<HTMLButtonElement>,
open: boolean,
options: string[],
activeIdx: number,
setActiveIdx: Dispatch<SetStateAction<number>>,
onToggle: () => void,
onSelect: (v: string) => void,
) {
if (!open) {
if (
e.key === "ArrowDown" ||
e.key === "ArrowUp" ||
e.key === "Enter" ||
e.key === " "
) {
e.preventDefault();
onToggle();
}
return;
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setActiveIdx((i) => Math.min(options.length - 1, i + 1));
break;
case "ArrowUp":
e.preventDefault();
setActiveIdx((i) => Math.max(0, i - 1));
break;
case "Home":
e.preventDefault();
setActiveIdx(0);
break;
case "End":
e.preventDefault();
setActiveIdx(options.length - 1);
break;
case "Enter":
case " ":
e.preventDefault();
if (activeIdx >= 0 && activeIdx < options.length) {
onSelect(options[activeIdx]);
}
break;
case "Escape":
e.preventDefault();
onToggle();
break;
case "Tab":
// do NOT preventDefault — let focus move naturally; just close the list
onToggle();
break;
default:
break;
}
}
function Dd({
@ -65,7 +137,23 @@ function Dd({
triggerColor = tokens.ink,
caretColor = tokens.muted2,
disabled = false,
ariaLabel,
}: DdProps) {
const listId = useId();
const optId = (i: number) => `${listId}-opt-${i}`;
const [activeIdx, setActiveIdx] = useState(-1);
// On open, point the virtual highlight at the current selection (fallback to
// the first option); reset when closed. Pure UI sync — not a data effect.
useEffect(() => {
if (open) {
const idx = options.indexOf(value);
setActiveIdx(idx >= 0 ? idx : 0);
} else {
setActiveIdx(-1);
}
}, [open, value, options]);
const panelStyle: CSSProperties = {
position: "absolute",
left: 0,
@ -88,7 +176,8 @@ function Dd({
return (
<div style={{ position: "relative" }}>
<div
<button
type="button"
className={
disabled
? "pp-dd-trigger-disabled"
@ -96,7 +185,31 @@ function Dd({
? "pp-dd-trigger-dashed"
: "pp-dd-trigger"
}
onClick={onToggle}
onClick={!disabled ? onToggle : undefined}
onKeyDown={
disabled
? undefined
: (e) =>
comboKeyDown(
e,
open,
options,
activeIdx,
setActiveIdx,
onToggle,
onSelect,
)
}
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listId}
aria-activedescendant={
open && activeIdx >= 0 ? optId(activeIdx) : undefined
}
aria-label={ariaLabel}
aria-disabled={disabled || undefined}
disabled={disabled}
style={{
height: triggerHeight,
background: triggerBg,
@ -109,6 +222,8 @@ function Dd({
fontSize: triggerFontSize,
color: triggerColor,
cursor: disabled ? "default" : "pointer",
width: "100%",
textAlign: "left",
}}
>
{value}
@ -123,14 +238,24 @@ function Dd({
</span>
)}
</div>
</button>
{open && (
<div style={panelStyle}>
{options.map((o) => (
<div
style={panelStyle}
role="listbox"
id={listId}
aria-label={ariaLabel}
tabIndex={-1}
>
{options.map((o, i) => (
<div
key={o}
className="pp-dd-opt"
role="option"
id={optId(i)}
aria-selected={o === value}
onClick={() => onSelect(o)}
onMouseEnter={() => setActiveIdx(i)}
style={{
padding: "7px 10px",
fontSize: optionFontSize,
@ -139,6 +264,7 @@ function Dd({
cursor: "pointer",
color: o === value ? tokens.accent : tokens.ink,
fontWeight: o === value ? 600 : 400,
background: i === activeIdx ? "rgba(46,139,255,.1)" : undefined,
}}
>
{o}
@ -353,6 +479,9 @@ export default function ParamsPanel({
markers = [],
}: ParamsPanelProps) {
const [openDd, setOpenDd] = useState<DdKey>(null);
// РАДИУС combobox a11y state (aria-activedescendant highlight + listbox id).
const radiusListId = useId();
const [radiusActive, setRadiusActive] = useState(-1);
// Map zoom — applied as transform: scale() on the map content layer (not the
// whole panel). Default 1 (identity → pixel-identical), step .25, clamp 12.5
// (no zoom-out below 1: there is no real basemap behind the blueprint SVG, so
@ -406,6 +535,9 @@ export default function ParamsPanel({
: null,
);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// БАЛКОН radiogroup focus targets (roving tabindex).
const balNoRef = useRef<HTMLButtonElement>(null);
const balYesRef = useRef<HTMLButtonElement>(null);
const suggest = useGeocodeSuggest(addressQuery);
const handleAddressChange = (v: string) => {
@ -436,6 +568,34 @@ export default function ParamsPanel({
setOpenDd(null);
};
// Keep the РАДИУС virtual highlight pointed at the open selection (UI sync,
// not a data effect) — mirrors the <Dd> internal effect.
useEffect(() => {
if (openDd === "radius") {
const idx = RADIUS_OPTIONS.indexOf(radius);
setRadiusActive(idx >= 0 ? idx : 0);
} else {
setRadiusActive(-1);
}
}, [openDd, radius]);
// БАЛКОН radiogroup keyboard: arrows pick + move focus; Space/Enter commit the
// focused option.
const handleBalconyKey = (e: ReactKeyboardEvent<HTMLButtonElement>) => {
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
setBalcony(true);
balYesRef.current?.focus();
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
setBalcony(false);
balNoRef.current?.focus();
} else if (e.key === " " || e.key === "Enter") {
e.preventDefault();
setBalcony(e.currentTarget === balYesRef.current);
}
};
const handleSubmit = () => {
const trimmedAddress = address.trim();
const areaNum = Number(area.replace(",", "."));
@ -493,8 +653,14 @@ export default function ParamsPanel({
display: "flex",
alignItems: "center",
justifyContent: "center",
// No Tailwind preflight here → reset UA <button> chrome explicitly so the
// radio pills keep the HUD font/size (UA buttons don't inherit font-family).
// `inherit` picks up the artboard's Manrope, matching the prior <div> + the
// map +/- buttons (strict parity, one approach).
fontFamily: "inherit",
fontSize: 11,
letterSpacing: 1,
padding: 0,
cursor: "pointer",
transition: "all .15s",
};
@ -701,7 +867,9 @@ export default function ParamsPanel({
gap: 5,
}}
>
<div
<button
type="button"
aria-label="Приблизить карту"
style={{
width: 24,
height: 24,
@ -715,12 +883,17 @@ export default function ParamsPanel({
transition: "all .15s",
fontSize: 15,
color: tokens.muted,
padding: 0,
fontFamily: "inherit",
}}
onClick={() => setZoom((z) => Math.min(2.5, z + 0.25))}
>
+
</div>
<div
</button>
<button
type="button"
aria-label="Отдалить карту"
disabled={zoom <= 1}
style={{
width: 24,
height: 24,
@ -735,13 +908,15 @@ export default function ParamsPanel({
fontSize: 15,
color: tokens.muted,
opacity: zoom <= 1 ? 0.4 : 1,
padding: 0,
fontFamily: "inherit",
}}
onClick={() => {
if (zoom > 1) setZoom((z) => Math.max(1, z - 0.25));
}}
>
</div>
</button>
</div>
{/* map corner bracket */}
@ -775,9 +950,31 @@ export default function ParamsPanel({
submit). Trigger markup unchanged; now toggles a Dd-style option panel
and uses the shared pp-dd-trigger hover cue. */}
<div style={{ position: "relative" }}>
<div
<button
type="button"
className="pp-dd-trigger"
onClick={() => toggle("radius")}
onKeyDown={(e) =>
comboKeyDown(
e,
openDd === "radius",
RADIUS_OPTIONS,
radiusActive,
setRadiusActive,
() => toggle("radius"),
pick(setRadius),
)
}
role="combobox"
aria-haspopup="listbox"
aria-expanded={openDd === "radius"}
aria-controls={radiusListId}
aria-activedescendant={
openDd === "radius" && radiusActive >= 0
? `${radiusListId}-opt-${radiusActive}`
: undefined
}
aria-label="Радиус анализа"
style={{
display: "flex",
alignItems: "center",
@ -787,19 +984,30 @@ export default function ParamsPanel({
padding: "6px 10px",
fontSize: 12,
fontFamily: tokens.font.mono,
color: "inherit",
cursor: "pointer",
}}
>
<span>{radius}</span>
<span style={{ color: tokens.muted2, fontSize: 9 }}></span>
</div>
</button>
{openDd === "radius" && (
<div style={radiusPanel}>
{RADIUS_OPTIONS.map((o) => (
<div
style={radiusPanel}
role="listbox"
id={radiusListId}
aria-label="Радиус анализа"
tabIndex={-1}
>
{RADIUS_OPTIONS.map((o, i) => (
<div
key={o}
className="pp-dd-opt"
role="option"
id={`${radiusListId}-opt-${i}`}
aria-selected={o === radius}
onClick={() => pick(setRadius)(o)}
onMouseEnter={() => setRadiusActive(i)}
style={{
padding: "7px 10px",
fontSize: 12,
@ -808,6 +1016,8 @@ export default function ParamsPanel({
cursor: "pointer",
color: o === radius ? tokens.accent : tokens.ink,
fontWeight: o === radius ? 600 : 400,
background:
i === radiusActive ? "rgba(46,139,255,.1)" : undefined,
}}
>
{o}
@ -863,16 +1073,26 @@ export default function ParamsPanel({
}}
>
<div>
<div style={hintLabel}>АДРЕС (YANDEX / NOMINATIM)</div>
<label
htmlFor="pp-address"
style={{ ...hintLabel, display: "block" }}
>
АДРЕС (YANDEX / NOMINATIM)
</label>
<div style={{ position: "relative" }}>
{/* Address autocomplete (geocode suggest, ЕКБ viewbox). Typed text
is debounced into `addressQuery`; the dropdown below mirrors the
HUD <Dd> panel styling. Picking an item fills the full address
and captures lat/lon for the estimate payload. */}
<input
id="pp-address"
className="pp-input"
type="text"
value={address}
aria-invalid={fieldErrors.address ? true : undefined}
aria-describedby={
fieldErrors.address ? "pp-address-err" : undefined
}
onChange={(e) => handleAddressChange(e.target.value)}
onFocus={() => {
if (address.trim().length >= 3) setSuggestOpen(true);
@ -937,7 +1157,11 @@ export default function ParamsPanel({
</div>
)}
{fieldErrors.address && (
<div style={{ ...errorText, marginTop: 4 }} role="alert">
<div
id="pp-address-err"
style={{ ...errorText, marginTop: 4 }}
role="alert"
>
{fieldErrors.address}
</div>
)}
@ -946,12 +1170,17 @@ export default function ParamsPanel({
<div style={fieldRow}>
<div>
<div style={hintLabel}>ПЛОЩАДЬ, М²</div>
<label htmlFor="pp-area" style={{ ...hintLabel, display: "block" }}>
ПЛОЩАДЬ, М²
</label>
<input
id="pp-area"
className="pp-input"
type="text"
inputMode="decimal"
value={area}
aria-invalid={fieldErrors.area ? true : undefined}
aria-describedby={fieldErrors.area ? "pp-area-err" : undefined}
onChange={(e) => {
setArea(e.target.value.replace(/[^\d.,]/g, ""));
if (fieldErrors.area)
@ -968,7 +1197,11 @@ export default function ParamsPanel({
}
/>
{fieldErrors.area && (
<div style={{ ...errorText, marginTop: 4 }} role="alert">
<div
id="pp-area-err"
style={{ ...errorText, marginTop: 4 }}
role="alert"
>
{fieldErrors.area}
</div>
)}
@ -984,6 +1217,7 @@ export default function ParamsPanel({
mono
triggerFontSize={14}
optionFontSize={13}
ariaLabel="Комнат"
/>
</div>
</div>
@ -991,10 +1225,13 @@ export default function ParamsPanel({
<div style={fieldRow}>
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>ЭТАЖ</span>
<label htmlFor="pp-floor" style={hintLabel}>
ЭТАЖ
</label>
<span style={optHint}>если знаете</span>
</div>
<input
id="pp-floor"
className="pp-input"
type="text"
inputMode="numeric"
@ -1009,10 +1246,13 @@ export default function ParamsPanel({
</div>
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>ВСЕГО ЭТАЖЕЙ</span>
<label htmlFor="pp-total-floors" style={hintLabel}>
ВСЕГО ЭТАЖЕЙ
</label>
<span style={optHint}>если знаете</span>
</div>
<input
id="pp-total-floors"
className="pp-input"
type="text"
inputMode="numeric"
@ -1043,10 +1283,13 @@ export default function ParamsPanel({
<div style={fieldRow}>
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>ГОД ПОСТРОЙКИ</span>
<label htmlFor="pp-year" style={hintLabel}>
ГОД ПОСТРОЙКИ
</label>
<span style={optHint}>опц.</span>
</div>
<input
id="pp-year"
className="pp-input"
type="text"
inputMode="numeric"
@ -1073,6 +1316,7 @@ export default function ParamsPanel({
mono={false}
triggerFontSize={13}
optionFontSize={12}
ariaLabel="Тип дома"
/>
</div>
</div>
@ -1092,23 +1336,40 @@ export default function ParamsPanel({
mono={false}
triggerFontSize={13}
optionFontSize={12}
ariaLabel="Состояние ремонта"
/>
</div>
<div>
<div style={hintLabel}>БАЛКОН / ЛОДЖИЯ</div>
<div style={{ height: 40, display: "flex", gap: 6 }}>
<div
<div
role="radiogroup"
aria-label="Балкон или лоджия"
style={{ height: 40, display: "flex", gap: 6 }}
>
<button
type="button"
role="radio"
aria-checked={balcony === false}
tabIndex={balcony === false || balcony === null ? 0 : -1}
ref={balNoRef}
onClick={() => setBalcony(false)}
onKeyDown={handleBalconyKey}
style={{ ...balBase, ...(balcony === false ? balOn : balOff) }}
>
НЕТ
</div>
<div
</button>
<button
type="button"
role="radio"
aria-checked={balcony === true}
tabIndex={balcony === true ? 0 : -1}
ref={balYesRef}
onClick={() => setBalcony(true)}
onKeyDown={handleBalconyKey}
style={{ ...balBase, ...(balcony === true ? balOn : balOff) }}
>
ДА
</div>
</button>
</div>
</div>
</div>
@ -1136,6 +1397,7 @@ export default function ParamsPanel({
triggerColor={tokens.hint}
caretColor={tokens.muted4}
disabled
ariaLabel="CRM — для менеджера"
/>
</div>
</div>