fix(tradein/v2): a11y P3b — клавиатурная форма 01 (#2062) #2078

Merged
lekss361 merged 1 commit from fix/tradein-v2-a11y-form into main 2026-06-28 18:58:35 +00:00

View file

@ -11,7 +11,16 @@
// the CRM dropdown still has no backend → kept visually but disabled. Hover/active // the CRM dropdown still has no backend → kept visually but disabled. Hover/active
// + @keyframes live in a pp-prefixed local <style>. // + @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 { tokens } from "./tokens";
import { dropdownOptions } from "./fixtures"; import { dropdownOptions } from "./fixtures";
import { import {
@ -47,6 +56,69 @@ interface DdProps {
triggerColor?: string; triggerColor?: string;
caretColor?: string; caretColor?: string;
disabled?: boolean; 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({ function Dd({
@ -65,7 +137,23 @@ function Dd({
triggerColor = tokens.ink, triggerColor = tokens.ink,
caretColor = tokens.muted2, caretColor = tokens.muted2,
disabled = false, disabled = false,
ariaLabel,
}: DdProps) { }: 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 = { const panelStyle: CSSProperties = {
position: "absolute", position: "absolute",
left: 0, left: 0,
@ -88,7 +176,8 @@ function Dd({
return ( return (
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
<div <button
type="button"
className={ className={
disabled disabled
? "pp-dd-trigger-disabled" ? "pp-dd-trigger-disabled"
@ -96,7 +185,31 @@ function Dd({
? "pp-dd-trigger-dashed" ? "pp-dd-trigger-dashed"
: "pp-dd-trigger" : "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={{ style={{
height: triggerHeight, height: triggerHeight,
background: triggerBg, background: triggerBg,
@ -109,6 +222,8 @@ function Dd({
fontSize: triggerFontSize, fontSize: triggerFontSize,
color: triggerColor, color: triggerColor,
cursor: disabled ? "default" : "pointer", cursor: disabled ? "default" : "pointer",
width: "100%",
textAlign: "left",
}} }}
> >
{value} {value}
@ -123,14 +238,24 @@ function Dd({
</span> </span>
)} )}
</div> </button>
{open && ( {open && (
<div style={panelStyle}> <div
{options.map((o) => ( style={panelStyle}
role="listbox"
id={listId}
aria-label={ariaLabel}
tabIndex={-1}
>
{options.map((o, i) => (
<div <div
key={o} key={o}
className="pp-dd-opt" className="pp-dd-opt"
role="option"
id={optId(i)}
aria-selected={o === value}
onClick={() => onSelect(o)} onClick={() => onSelect(o)}
onMouseEnter={() => setActiveIdx(i)}
style={{ style={{
padding: "7px 10px", padding: "7px 10px",
fontSize: optionFontSize, fontSize: optionFontSize,
@ -139,6 +264,7 @@ function Dd({
cursor: "pointer", cursor: "pointer",
color: o === value ? tokens.accent : tokens.ink, color: o === value ? tokens.accent : tokens.ink,
fontWeight: o === value ? 600 : 400, fontWeight: o === value ? 600 : 400,
background: i === activeIdx ? "rgba(46,139,255,.1)" : undefined,
}} }}
> >
{o} {o}
@ -353,6 +479,9 @@ export default function ParamsPanel({
markers = [], markers = [],
}: ParamsPanelProps) { }: ParamsPanelProps) {
const [openDd, setOpenDd] = useState<DdKey>(null); 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 // 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 // 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 // (no zoom-out below 1: there is no real basemap behind the blueprint SVG, so
@ -406,6 +535,9 @@ export default function ParamsPanel({
: null, : null,
); );
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(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 suggest = useGeocodeSuggest(addressQuery);
const handleAddressChange = (v: string) => { const handleAddressChange = (v: string) => {
@ -436,6 +568,34 @@ export default function ParamsPanel({
setOpenDd(null); 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 handleSubmit = () => {
const trimmedAddress = address.trim(); const trimmedAddress = address.trim();
const areaNum = Number(area.replace(",", ".")); const areaNum = Number(area.replace(",", "."));
@ -493,8 +653,14 @@ export default function ParamsPanel({
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "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, fontSize: 11,
letterSpacing: 1, letterSpacing: 1,
padding: 0,
cursor: "pointer", cursor: "pointer",
transition: "all .15s", transition: "all .15s",
}; };
@ -701,7 +867,9 @@ export default function ParamsPanel({
gap: 5, gap: 5,
}} }}
> >
<div <button
type="button"
aria-label="Приблизить карту"
style={{ style={{
width: 24, width: 24,
height: 24, height: 24,
@ -715,12 +883,17 @@ export default function ParamsPanel({
transition: "all .15s", transition: "all .15s",
fontSize: 15, fontSize: 15,
color: tokens.muted, color: tokens.muted,
padding: 0,
fontFamily: "inherit",
}} }}
onClick={() => setZoom((z) => Math.min(2.5, z + 0.25))} onClick={() => setZoom((z) => Math.min(2.5, z + 0.25))}
> >
+ +
</div> </button>
<div <button
type="button"
aria-label="Отдалить карту"
disabled={zoom <= 1}
style={{ style={{
width: 24, width: 24,
height: 24, height: 24,
@ -735,13 +908,15 @@ export default function ParamsPanel({
fontSize: 15, fontSize: 15,
color: tokens.muted, color: tokens.muted,
opacity: zoom <= 1 ? 0.4 : 1, opacity: zoom <= 1 ? 0.4 : 1,
padding: 0,
fontFamily: "inherit",
}} }}
onClick={() => { onClick={() => {
if (zoom > 1) setZoom((z) => Math.max(1, z - 0.25)); if (zoom > 1) setZoom((z) => Math.max(1, z - 0.25));
}} }}
> >
</div> </button>
</div> </div>
{/* map corner bracket */} {/* map corner bracket */}
@ -775,9 +950,31 @@ export default function ParamsPanel({
submit). Trigger markup unchanged; now toggles a Dd-style option panel submit). Trigger markup unchanged; now toggles a Dd-style option panel
and uses the shared pp-dd-trigger hover cue. */} and uses the shared pp-dd-trigger hover cue. */}
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
<div <button
type="button"
className="pp-dd-trigger" className="pp-dd-trigger"
onClick={() => toggle("radius")} 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={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
@ -787,19 +984,30 @@ export default function ParamsPanel({
padding: "6px 10px", padding: "6px 10px",
fontSize: 12, fontSize: 12,
fontFamily: tokens.font.mono, fontFamily: tokens.font.mono,
color: "inherit",
cursor: "pointer", cursor: "pointer",
}} }}
> >
<span>{radius}</span> <span>{radius}</span>
<span style={{ color: tokens.muted2, fontSize: 9 }}></span> <span style={{ color: tokens.muted2, fontSize: 9 }}></span>
</div> </button>
{openDd === "radius" && ( {openDd === "radius" && (
<div style={radiusPanel}> <div
{RADIUS_OPTIONS.map((o) => ( style={radiusPanel}
role="listbox"
id={radiusListId}
aria-label="Радиус анализа"
tabIndex={-1}
>
{RADIUS_OPTIONS.map((o, i) => (
<div <div
key={o} key={o}
className="pp-dd-opt" className="pp-dd-opt"
role="option"
id={`${radiusListId}-opt-${i}`}
aria-selected={o === radius}
onClick={() => pick(setRadius)(o)} onClick={() => pick(setRadius)(o)}
onMouseEnter={() => setRadiusActive(i)}
style={{ style={{
padding: "7px 10px", padding: "7px 10px",
fontSize: 12, fontSize: 12,
@ -808,6 +1016,8 @@ export default function ParamsPanel({
cursor: "pointer", cursor: "pointer",
color: o === radius ? tokens.accent : tokens.ink, color: o === radius ? tokens.accent : tokens.ink,
fontWeight: o === radius ? 600 : 400, fontWeight: o === radius ? 600 : 400,
background:
i === radiusActive ? "rgba(46,139,255,.1)" : undefined,
}} }}
> >
{o} {o}
@ -863,16 +1073,26 @@ export default function ParamsPanel({
}} }}
> >
<div> <div>
<div style={hintLabel}>АДРЕС (YANDEX / NOMINATIM)</div> <label
htmlFor="pp-address"
style={{ ...hintLabel, display: "block" }}
>
АДРЕС (YANDEX / NOMINATIM)
</label>
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
{/* Address autocomplete (geocode suggest, ЕКБ viewbox). Typed text {/* Address autocomplete (geocode suggest, ЕКБ viewbox). Typed text
is debounced into `addressQuery`; the dropdown below mirrors the is debounced into `addressQuery`; the dropdown below mirrors the
HUD <Dd> panel styling. Picking an item fills the full address HUD <Dd> panel styling. Picking an item fills the full address
and captures lat/lon for the estimate payload. */} and captures lat/lon for the estimate payload. */}
<input <input
id="pp-address"
className="pp-input" className="pp-input"
type="text" type="text"
value={address} value={address}
aria-invalid={fieldErrors.address ? true : undefined}
aria-describedby={
fieldErrors.address ? "pp-address-err" : undefined
}
onChange={(e) => handleAddressChange(e.target.value)} onChange={(e) => handleAddressChange(e.target.value)}
onFocus={() => { onFocus={() => {
if (address.trim().length >= 3) setSuggestOpen(true); if (address.trim().length >= 3) setSuggestOpen(true);
@ -937,7 +1157,11 @@ export default function ParamsPanel({
</div> </div>
)} )}
{fieldErrors.address && ( {fieldErrors.address && (
<div style={{ ...errorText, marginTop: 4 }} role="alert"> <div
id="pp-address-err"
style={{ ...errorText, marginTop: 4 }}
role="alert"
>
{fieldErrors.address} {fieldErrors.address}
</div> </div>
)} )}
@ -946,12 +1170,17 @@ export default function ParamsPanel({
<div style={fieldRow}> <div style={fieldRow}>
<div> <div>
<div style={hintLabel}>ПЛОЩАДЬ, М²</div> <label htmlFor="pp-area" style={{ ...hintLabel, display: "block" }}>
ПЛОЩАДЬ, М²
</label>
<input <input
id="pp-area"
className="pp-input" className="pp-input"
type="text" type="text"
inputMode="decimal" inputMode="decimal"
value={area} value={area}
aria-invalid={fieldErrors.area ? true : undefined}
aria-describedby={fieldErrors.area ? "pp-area-err" : undefined}
onChange={(e) => { onChange={(e) => {
setArea(e.target.value.replace(/[^\d.,]/g, "")); setArea(e.target.value.replace(/[^\d.,]/g, ""));
if (fieldErrors.area) if (fieldErrors.area)
@ -968,7 +1197,11 @@ export default function ParamsPanel({
} }
/> />
{fieldErrors.area && ( {fieldErrors.area && (
<div style={{ ...errorText, marginTop: 4 }} role="alert"> <div
id="pp-area-err"
style={{ ...errorText, marginTop: 4 }}
role="alert"
>
{fieldErrors.area} {fieldErrors.area}
</div> </div>
)} )}
@ -984,6 +1217,7 @@ export default function ParamsPanel({
mono mono
triggerFontSize={14} triggerFontSize={14}
optionFontSize={13} optionFontSize={13}
ariaLabel="Комнат"
/> />
</div> </div>
</div> </div>
@ -991,10 +1225,13 @@ export default function ParamsPanel({
<div style={fieldRow}> <div style={fieldRow}>
<div> <div>
<div style={{ display: "flex", justifyContent: "space-between" }}> <div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>ЭТАЖ</span> <label htmlFor="pp-floor" style={hintLabel}>
ЭТАЖ
</label>
<span style={optHint}>если знаете</span> <span style={optHint}>если знаете</span>
</div> </div>
<input <input
id="pp-floor"
className="pp-input" className="pp-input"
type="text" type="text"
inputMode="numeric" inputMode="numeric"
@ -1009,10 +1246,13 @@ export default function ParamsPanel({
</div> </div>
<div> <div>
<div style={{ display: "flex", justifyContent: "space-between" }}> <div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>ВСЕГО ЭТАЖЕЙ</span> <label htmlFor="pp-total-floors" style={hintLabel}>
ВСЕГО ЭТАЖЕЙ
</label>
<span style={optHint}>если знаете</span> <span style={optHint}>если знаете</span>
</div> </div>
<input <input
id="pp-total-floors"
className="pp-input" className="pp-input"
type="text" type="text"
inputMode="numeric" inputMode="numeric"
@ -1043,10 +1283,13 @@ export default function ParamsPanel({
<div style={fieldRow}> <div style={fieldRow}>
<div> <div>
<div style={{ display: "flex", justifyContent: "space-between" }}> <div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>ГОД ПОСТРОЙКИ</span> <label htmlFor="pp-year" style={hintLabel}>
ГОД ПОСТРОЙКИ
</label>
<span style={optHint}>опц.</span> <span style={optHint}>опц.</span>
</div> </div>
<input <input
id="pp-year"
className="pp-input" className="pp-input"
type="text" type="text"
inputMode="numeric" inputMode="numeric"
@ -1073,6 +1316,7 @@ export default function ParamsPanel({
mono={false} mono={false}
triggerFontSize={13} triggerFontSize={13}
optionFontSize={12} optionFontSize={12}
ariaLabel="Тип дома"
/> />
</div> </div>
</div> </div>
@ -1092,23 +1336,40 @@ export default function ParamsPanel({
mono={false} mono={false}
triggerFontSize={13} triggerFontSize={13}
optionFontSize={12} optionFontSize={12}
ariaLabel="Состояние ремонта"
/> />
</div> </div>
<div> <div>
<div style={hintLabel}>БАЛКОН / ЛОДЖИЯ</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)} onClick={() => setBalcony(false)}
onKeyDown={handleBalconyKey}
style={{ ...balBase, ...(balcony === false ? balOn : balOff) }} style={{ ...balBase, ...(balcony === false ? balOn : balOff) }}
> >
НЕТ НЕТ
</div> </button>
<div <button
type="button"
role="radio"
aria-checked={balcony === true}
tabIndex={balcony === true ? 0 : -1}
ref={balYesRef}
onClick={() => setBalcony(true)} onClick={() => setBalcony(true)}
onKeyDown={handleBalconyKey}
style={{ ...balBase, ...(balcony === true ? balOn : balOff) }} style={{ ...balBase, ...(balcony === true ? balOn : balOff) }}
> >
ДА ДА
</div> </button>
</div> </div>
</div> </div>
</div> </div>
@ -1136,6 +1397,7 @@ export default function ParamsPanel({
triggerColor={tokens.hint} triggerColor={tokens.hint}
caretColor={tokens.muted4} caretColor={tokens.muted4}
disabled disabled
ariaLabel="CRM — для менеджера"
/> />
</div> </div>
</div> </div>