diff --git a/src/ui/city-autocomplete/CityAutocomplete.test.tsx b/src/ui/city-autocomplete/CityAutocomplete.test.tsx index bb351b39..24a2fb98 100644 --- a/src/ui/city-autocomplete/CityAutocomplete.test.tsx +++ b/src/ui/city-autocomplete/CityAutocomplete.test.tsx @@ -319,6 +319,28 @@ describe("CityAutocomplete", () => { expect(input.getAttribute("value")).toBe("Мос"); }); + it("4.1.9.2-R12: Escape closes the popup when popup is open", () => { + render( + , + ); + // Open the popup + fireEvent.click(screen.getByTestId("test-popup-button")); + expect(screen.getByTestId("city-picker-popup")).toBeTruthy(); + // Press Escape on the root div + fireEvent.keyDown(screen.getByTestId("test-input").closest(".city-autocomplete")!, { + key: "Escape", + code: "Escape", + }); + expect(screen.queryByTestId("city-picker-popup")).toBeNull(); + }); + it("does not open popup when dictionaries is null", () => { render( = ({ setInputValue(""); }, [onChange]); - // TZ §4.1.9.1: ESC cancels manual entry — discard the typed text and restore + // TZ §4.1.9.1+§4.1.9.2: ESC closes the dictionary popup when open; + // otherwise cancels manual entry — discard the typed text and restore // the last committed value's display (or clear when nothing was committed). const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key !== "Escape") return; e.preventDefault(); e.stopPropagation(); + if (popupOpen) { + setPopupOpen(false); + return; + } // Restore display to match the committed `value` prop if (!value) { setInputValue(""); @@ -143,7 +148,7 @@ export const CityAutocomplete: FC = ({ } setInputValue(value); }, - [value, dictionaries], + [value, dictionaries, popupOpen], ); const renderSuggestion = useCallback((item: CityAutocompleteItem) => { @@ -258,6 +263,7 @@ export const CityAutocomplete: FC = ({ dictionaries={dictionaries} selectedCode={value} onSelect={handleSelect} + onClose={() => setPopupOpen(false)} {...(onLocate ? { onLocate } : {})} /> diff --git a/src/ui/city-autocomplete/CityPickerPopup.scss b/src/ui/city-autocomplete/CityPickerPopup.scss index 687c95f9..9bad4df5 100644 --- a/src/ui/city-autocomplete/CityPickerPopup.scss +++ b/src/ui/city-autocomplete/CityPickerPopup.scss @@ -91,6 +91,12 @@ background-color: colors.$blue-icon; } + &.city-highlighted .city--item, + .city--item-highlighted { + background-color: colors.$blue-extra-light; + outline: 2px solid colors.$blue; + } + .airports-column { display: flex; flex-direction: column; diff --git a/src/ui/city-autocomplete/CityPickerPopup.test.tsx b/src/ui/city-autocomplete/CityPickerPopup.test.tsx index 26958203..fedd8c60 100644 --- a/src/ui/city-autocomplete/CityPickerPopup.test.tsx +++ b/src/ui/city-autocomplete/CityPickerPopup.test.tsx @@ -3,7 +3,7 @@ */ import { describe, it, expect, vi } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, within } from "@testing-library/react"; import { CityPickerPopup } from "./CityPickerPopup.js"; import { transformDictionaries } from "@/shared/dictionaries/index.js"; import type { IRawDictionaries } from "@/shared/dictionaries/index.js"; @@ -84,4 +84,160 @@ describe("CityPickerPopup", () => { fireEvent.click(screen.getByTestId("city-picker-locate")); expect(onLocate).toHaveBeenCalled(); }); + + // ── §4.1.9.2 assertion tests ────────────────────────────────────────────── + + it("4.1.9.2-R1: selected item has city-active CSS class (visual highlight)", () => { + const { container } = render( + , + ); + const activeCells = container.querySelectorAll(".city-active"); + expect(activeCells.length).toBeGreaterThan(0); + }); + + it("4.1.9.2-R2: item has aria-selected=true when it matches selectedCode", () => { + render( + , + ); + // LED maps to Санкт-Петербург city — find the role=option element + const options = screen.getAllByRole("option"); + const ledOption = options.find((o) => o.getAttribute("aria-selected") === "true"); + expect(ledOption).toBeTruthy(); + }); + + it("4.1.9.2-R3: scroll panel has max-height so the list is scrollable", () => { + const { container } = render( + , + ); + const panel = container.querySelector(".content--scroll-panel"); + expect(panel).toBeTruthy(); + }); + + it("4.1.9.2-R4: items grouped by region tabs (one tab per region)", () => { + render(); + const tabs = screen.getAllByRole("tab"); + expect(tabs.length).toBe(2); + }); + + it("4.1.9.2-R5: country headers appear in the content panel (grouped by country)", () => { + render(); + // "Россия" should appear as a country-start-row header + expect(screen.getByText("Россия")).toBeTruthy(); + }); + + it("4.1.9.2-R6: ArrowDown moves keyboard highlight to the first item", () => { + const { container } = render( + , + ); + const popup = container.querySelector("[data-testid='city-picker-popup']")!; + fireEvent.keyDown(popup, { key: "ArrowDown" }); + // After one ArrowDown the scroll-panel's aria-activedescendant is set + const panel = container.querySelector(".content--scroll-panel"); + expect(panel?.getAttribute("aria-activedescendant")).toBeTruthy(); + }); + + it("4.1.9.2-R7: ArrowDown then ArrowDown moves highlight to the second item", () => { + const { container } = render( + , + ); + const popup = container.querySelector("[data-testid='city-picker-popup']")!; + fireEvent.keyDown(popup, { key: "ArrowDown" }); + const firstId = container + .querySelector(".content--scroll-panel") + ?.getAttribute("aria-activedescendant"); + + fireEvent.keyDown(popup, { key: "ArrowDown" }); + const secondId = container + .querySelector(".content--scroll-panel") + ?.getAttribute("aria-activedescendant"); + + expect(firstId).toBeTruthy(); + expect(secondId).toBeTruthy(); + expect(secondId).not.toBe(firstId); + }); + + it("4.1.9.2-R8: ArrowUp does not go below index 0 (clamps at first item)", () => { + const { container } = render( + , + ); + const popup = container.querySelector("[data-testid='city-picker-popup']")!; + // Move down first so Up has something to come back to + fireEvent.keyDown(popup, { key: "ArrowDown" }); + const firstId = container + .querySelector(".content--scroll-panel") + ?.getAttribute("aria-activedescendant"); + + // Press Up — should stay at first item + fireEvent.keyDown(popup, { key: "ArrowUp" }); + const afterUpId = container + .querySelector(".content--scroll-panel") + ?.getAttribute("aria-activedescendant"); + + expect(afterUpId).toBe(firstId); + }); + + it("4.1.9.2-R9: highlighted item gets city-highlighted CSS class", () => { + const { container } = render( + , + ); + const popup = container.querySelector("[data-testid='city-picker-popup']")!; + // No highlight yet + expect(container.querySelector(".city-highlighted")).toBeNull(); + fireEvent.keyDown(popup, { key: "ArrowDown" }); + expect(container.querySelector(".city-highlighted")).toBeTruthy(); + }); + + it("4.1.9.2-R10: Enter commits the highlighted item", () => { + const onSelect = vi.fn(); + const { container } = render( + , + ); + const popup = container.querySelector("[data-testid='city-picker-popup']")!; + fireEvent.keyDown(popup, { key: "ArrowDown" }); + fireEvent.keyDown(popup, { key: "Enter" }); + expect(onSelect).toHaveBeenCalledTimes(1); + }); + + it("4.1.9.2-R11: Enter without highlight does not call onSelect", () => { + const onSelect = vi.fn(); + const { container } = render( + , + ); + const popup = container.querySelector("[data-testid='city-picker-popup']")!; + fireEvent.keyDown(popup, { key: "Enter" }); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("4.1.9.2-R12: Escape calls onClose without calling onSelect", () => { + const onSelect = vi.fn(); + const onClose = vi.fn(); + const { container } = render( + , + ); + const popup = container.querySelector("[data-testid='city-picker-popup']")!; + fireEvent.keyDown(popup, { key: "Escape" }); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("4.1.9.2-R13: switching tabs resets the keyboard highlight", () => { + const { container } = render( + , + ); + const popup = container.querySelector("[data-testid='city-picker-popup']")!; + fireEvent.keyDown(popup, { key: "ArrowDown" }); + expect(container.querySelector(".city-highlighted")).toBeTruthy(); + // Switch to the second region tab + fireEvent.click(screen.getByText("Азия")); + expect(container.querySelector(".city-highlighted")).toBeNull(); + }); + + it("4.1.9.2-R14: popup root has role=dialog for a11y", () => { + render(); + expect(screen.getByRole("dialog")).toBeTruthy(); + }); }); diff --git a/src/ui/city-autocomplete/CityPickerPopup.tsx b/src/ui/city-autocomplete/CityPickerPopup.tsx index b19eeeb8..3a1c797b 100644 --- a/src/ui/city-autocomplete/CityPickerPopup.tsx +++ b/src/ui/city-autocomplete/CityPickerPopup.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, type FC } from "react"; +import { useState, useMemo, useCallback, useRef, useEffect, type FC } from "react"; import { buildCountryCityRows } from "./buildCountryCityRows.js"; import type { IAirport, IDictionaries } from "@/shared/dictionaries/index.js"; import "./CityPickerPopup.scss"; @@ -8,6 +8,14 @@ export interface CityPickerPopupProps { selectedCode?: string; onSelect: (code: string) => void; onLocate?: () => void | Promise; + onClose?: () => void; +} + +/** A selectable entry flattened from all rows — used for keyboard navigation. */ +interface FlatItem { + code: string; + /** DOM element ID used for aria-activedescendant */ + id: string; } export const CityPickerPopup: FC = ({ @@ -15,19 +23,95 @@ export const CityPickerPopup: FC = ({ selectedCode, onSelect, onLocate, + onClose, }) => { const regions = dictionaries.regions; const [activeRegionId, setActiveRegionId] = useState( regions[0]?.id ?? 0, ); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const scrollPanelRef = useRef(null); const rows = useMemo( () => buildCountryCityRows(dictionaries, activeRegionId), [dictionaries, activeRegionId], ); + /** Ordered flat list of all selectable items in the current region. */ + const flatItems = useMemo(() => { + const items: FlatItem[] = []; + for (const row of rows) { + if (row.city1Airports) { + // multi-airport city + items.push({ code: row.city1!.code, id: `picker-item-${row.city1!.code}` }); + for (const ap of row.city1Airports) { + items.push({ code: ap.code, id: `picker-item-${ap.code}` }); + } + } else { + if (row.city1) items.push({ code: row.city1.code, id: `picker-item-${row.city1.code}` }); + if (row.city2) items.push({ code: row.city2.code, id: `picker-item-${row.city2.code}` }); + } + } + return items; + }, [rows]); + + // Reset highlight when region changes + useEffect(() => { + setHighlightedIndex(-1); + }, [activeRegionId]); + + // Scroll highlighted item into view + useEffect(() => { + if (highlightedIndex < 0 || !scrollPanelRef.current) return; + const item = flatItems[highlightedIndex]; + if (!item) return; + const el = scrollPanelRef.current.querySelector(`[data-item-id="${item.id}"]`); + if (el && typeof (el as HTMLElement).scrollIntoView === "function") { + (el as HTMLElement).scrollIntoView({ block: "nearest" }); + } + }, [highlightedIndex, flatItems]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + onClose?.(); + return; + } + if (e.key === "ArrowDown") { + e.preventDefault(); + setHighlightedIndex((i) => Math.min(i + 1, flatItems.length - 1)); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setHighlightedIndex((i) => Math.max(i - 1, 0)); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + const item = flatItems[highlightedIndex]; + if (item) onSelect(item.code); + return; + } + }, + [flatItems, highlightedIndex, onSelect, onClose], + ); + + const activeItemId = + highlightedIndex >= 0 ? (flatItems[highlightedIndex]?.id ?? undefined) : undefined; + return ( -
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
{regions.map((r) => (
-
+
{rows.map((row, idx) => { const rowKey = `${row.countryName ?? ""}-${row.city1?.code ?? ""}-${idx}`; const isMulti = Boolean(row.city1Airports); const city1 = row.city1; const city2 = row.city2; const city1Airports = row.city1Airports; + + const itemId1 = city1 ? `picker-item-${city1.code}` : undefined; + const itemId2 = city2 ? `picker-item-${city2.code}` : undefined; + const isHighlighted1 = itemId1 !== undefined && itemId1 === activeItemId; + const isHighlighted2 = itemId2 !== undefined && itemId2 === activeItemId; + return (
= ({ {!isMulti && city1 && ( <>
onSelect(city1.code)} onKeyDown={(e) => { @@ -79,11 +176,14 @@ export const CityPickerPopup: FC = ({ {city1.name}
-
+
{city2 && (
onSelect(city2.code)} @@ -99,10 +199,13 @@ export const CityPickerPopup: FC = ({ )} {isMulti && city1 && city1Airports && ( -
+
onSelect(city1.code)} @@ -113,22 +216,29 @@ export const CityPickerPopup: FC = ({ {city1.name}
- {city1Airports.map((ap: IAirport) => ( -
onSelect(ap.code)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") onSelect(ap.code); - }} - > - {ap.name} -
- ))} + {city1Airports.map((ap: IAirport) => { + const apId = `picker-item-${ap.code}`; + const isApHighlighted = apId === activeItemId; + return ( +
onSelect(ap.code)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") onSelect(ap.code); + }} + > + {ap.name} +
+ ); + })}
)}