fix(tradein/v2): a11y P3b — клавиатурная форма 01 (#2062) #2078
1 changed files with 288 additions and 26 deletions
|
|
@ -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 1–2.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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue