Add design spec for CityAutocomplete + regional picker parity

Cross-feature Angular-parity component: composite CityAutocomplete
(typeahead + clear + regional-picker trigger button) + CityPickerPopup
(regional tabs + country/city grid + optional GPS) + pure
buildCountryCityRows helper. Consumers: OnlineBoardFilter Route tab
and FlightsMapFilter. Time-selector switches to compact view in both.
This commit is contained in:
2026-04-17 14:25:52 +03:00
parent c4ae1ef7aa
commit 0534b373f0
@@ -0,0 +1,261 @@
# City Autocomplete + Regional Picker — Design
**Date:** 2026-04-17
**Scope:** Cross-feature Angular-parity component for city selection, used by OnlineBoard Route filter and Flights Map filter.
**Status:** Approved
**Depends on:** Flights Map C.1 (dictionaries) — landed.
## Goal
Bring React's city-selection inputs to full Angular parity by introducing a composite `<CityAutocomplete>` component (label + typeahead + clear button + regional-picker trigger button) plus a `<CityPickerPopup>` (regional tabs + country/city grid + optional GPS). Apply to both OnlineBoardFilter (Route tab, departure + arrival) and FlightsMapFilter (departure + arrival). Also switch the time-selector in both to Angular's compact (`fullView=false`) layout.
## Non-Goals
- New feature-flag infrastructure. GPS button visibility is driven by whether the consumer passes `onLocate`.
- Changes to the Flight-Number tab's flight number input — that's a different composite (`number-input-composite`) that already matches Angular.
- Changes to dictionary data or transform rules (C.1 is unchanged).
- Replacing PrimeReact's `<AutoComplete>` primitive — it stays as the internal typeahead engine.
## Architecture
```
src/ui/city-autocomplete/
├── CityAutocomplete.tsx Composite: label + typeahead + clear + popup-trigger
├── CityAutocomplete.scss
├── CityAutocomplete.test.tsx
├── CityPickerPopup.tsx Regional tabs + country/city grid + optional GPS
├── CityPickerPopup.scss
├── CityPickerPopup.test.tsx
├── buildCountryCityRows.ts Pure row-building logic (Angular parity)
├── buildCountryCityRows.test.ts
└── index.ts Barrel
```
Consumer changes:
- `src/features/online-board/components/OnlineBoardFilter.tsx` — Route tab uses `<CityAutocomplete>` for both departure + arrival; removes the two inline `<AutoComplete>` blocks and their `useCitySearch` usage for the Route tab only (Flight-Number tab unchanged).
- `src/features/flights-map/components/FlightsMapFilter.tsx` — both inputs use `<CityAutocomplete>`; departure receives `onLocate` callback wired to `navigator.geolocation.getCurrentPosition` + `findCityByCoord`.
- Both pages: time-selector wrapper switches from `full-view` to `compact-view` class, layout reshapes to label+value single row.
## Component: `CityAutocomplete`
**Props:**
```ts
interface CityAutocompleteProps {
label: string;
placeholder: string;
value: string; // IATA code (empty when cleared)
onChange: (code: string) => void;
dictionaries: IDictionaries | null;
onLocate?: () => void | Promise<void>; // hides GPS button when undefined
error?: string; // translation key, shown as tooltip
testIdPrefix?: string; // default "city-autocomplete"
}
```
**Behavior:**
- Typeahead search order (matches Angular `filterCity`):
1. Exact 3-char uppercase city code → one suggestion.
2. Substring match on `city.name` (localized) → up to 15 results.
3. Airport code → resolve to city, one suggestion.
- Clear button (`×`) resets `value` to `""`. SCSS `--has-value` shows it only when a city is selected.
- Popup-trigger button (square, right of input) toggles `<CityPickerPopup>`.
- Outside-click closes the popup via `document.addEventListener("mousedown", ...)`.
- `error` prop: renders tooltip under the label row, applies `--has-error` class to input container.
- When `value` changes externally (e.g., geolocation sets departure), internal input state syncs via `useEffect`.
**Internal structure (JSX outline):**
```
<div className="city-autocomplete" ref>
<div className="city-autocomplete__labels-container">
<label>{label}</label>
<label data-testid={`${prefix}-code`}>{selectedCity?.code ?? ""}</label>
</div>
{error && <div className="tooltip">{error}</div>}
<div className="city-autocomplete__input has-value? has-error?">
<AutoComplete ... /> ← PrimeReact typeahead
<button className="button-clear" />
<button className="city-autocomplete__search-button opened?" />
</div>
{popupOpen && <CityPickerPopup ... />}
</div>
```
## Component: `CityPickerPopup`
**Props:**
```ts
interface CityPickerPopupProps {
dictionaries: IDictionaries;
selectedCode?: string;
onSelect: (code: string) => void;
onLocate?: () => void | Promise<void>;
}
```
**Behavior:**
- Renders one `<button>` per region in `dictionaries.regions`, plus the active-tab state.
- Active tab drives `buildCountryCityRows(dictionaries, regionId)` → list of rows.
- Each row: country cell (on first-of-country rows) + city1 cell + city2 cell (or airports column when city1 has multiple airports).
- Clicking a city/airport cell calls `onSelect(code)`.
- `selectedCode` highlights the matching cell via `city-active` class.
- GPS footer renders only when `onLocate` is passed.
- Keyboard: `Enter`/`Space` on a cell triggers `onSelect`.
## Helper: `buildCountryCityRows`
Pure function. Returns `ICountryCityRow[]`:
```ts
interface ICountryCityRow {
countryName?: string; // set only on first row of each country
city1: ICity | null;
city2: ICity | null;
city1Airports?: IAirport[]; // present when city1 has >1 airport (alternate layout)
}
```
**Rules (Angular parity from `DictionariesService.handleLoading`):**
1. Countries within a region: alphabetical by localized name; RU pinned first if present.
2. Cities per country: cities with matching `country_code`, excluding `MOW` in initial sort, re-added at front when country is RU.
3. Row packing:
- `city1.airports.length > 1` → row alone, `city1Airports` populated; next city starts a new row.
- Otherwise pair `city1 + city2 (next)` on the same row.
4. `countryName` set only on the first row of each country block.
5. Empty country contributes no rows.
## Consumer: `OnlineBoardFilter.tsx`
The Flight-Number tab is untouched. The Route tab's two `<AutoComplete>` blocks are replaced with `<CityAutocomplete>`:
```tsx
<CityAutocomplete
label={t("SHARED.DEPARTURE_CITY")}
placeholder={t("SHARED.CITY_PLACEHOLDER")}
value={routeDepartureCode}
onChange={setRouteDepartureCode}
dictionaries={dictionaries}
testIdPrefix="route-departure"
/>
{/* swap button unchanged */}
<CityAutocomplete
label={t("SHARED.ARRIVAL_CITY")}
placeholder={t("SHARED.CITY_PLACEHOLDER")}
value={routeArrivalCode}
onChange={setRouteArrivalCode}
dictionaries={dictionaries}
testIdPrefix="route-arrival"
/>
```
Internal state shape changes from `CitySuggestion | string` to `string` for both inputs. Add `const { dictionaries } = useDictionaries(lang);` near the top of the component (lang from `useParams`). Existing `useCitySearch` imports remain — still used by the legacy "departure" / "arrival" flight-number inputs (if any). Remove only the unused-after-refactor paths.
Existing test ids (`route-departure-input`, `route-arrival-input`, `swap-cities-button`) are preserved via `testIdPrefix="route-departure"` / `testIdPrefix="route-arrival"` mappings. Any existing tests that target these stay valid.
Time-selector: change `full-view``compact-view`; restructure label+value into a single row above the slider.
## Consumer: `FlightsMapFilter.tsx`
Both inputs use `<CityAutocomplete>`. The departure input also gets an `onLocate` handler wired to browser geolocation + `findCityByCoord`:
```tsx
const handleLocate = useCallback(async () => {
if (!dictionaries || !navigator.geolocation) return;
navigator.geolocation.getCurrentPosition((pos) => {
const city = findCityByCoord(dictionaries, pos.coords.latitude, pos.coords.longitude);
if (city) onChange({ ...value, departure: city.code });
});
}, [dictionaries, onChange, value]);
```
Arrival input does NOT receive `onLocate` — GPS only makes sense for departure.
Note: the existing mount-time `useGeolocationDefault` hook already sets departure on page load. The manual GPS button is additional — the user may have cleared the initial value and wants geolocation again.
Time-selector: same `full-view``compact-view` switch applies if the flights-map filter has one (check at implementation time; skip this part if absent).
## Styling
Port values from Angular SCSS:
- `ClientApp/src/app/components/city-autocomplete/city-autocomplete.component.scss`
- `ClientApp/src/app/components/city-autocomplete/city-select/city-select.component.scss`
Key class names preserved verbatim so the visual match is pixel-close. The PrimeReact `<AutoComplete>` internal structure needs some `:global()` overrides to strip its default styling in favor of ours — match the Angular approach where `p-autoComplete` is wrapped and its chrome is reset.
## Error handling / edge cases
- **`dictionaries === null`** (loading/error): popup-trigger still renders but does nothing (popup won't open because the render-guard skips when dictionaries null). Typeahead returns empty suggestions.
- **Selected `value` not in dictionaries**: `selectedCity?.code` stays empty; the code label in the top-right is empty. No crash.
- **Region with empty country list**: tab renders but grid shows no rows. User can pick another tab.
- **GPS denied / unavailable**: `onLocate` handler runs, geolocation API throws silently, no change to filter state. Matches C.5 behavior.
- **Popup open + external value change** (e.g., swap button): `value` syncs via `useEffect`; popup stays open; grid reflects new `selectedCode`.
- **Rapid typeahead changes**: each `handleComplete` call recomputes independently — no race conditions (pure function of dictionaries + query).
- **Keyboard a11y**: popup opens/closes via click-only (matching Angular). Focus trap inside popup not in scope for parity.
## Testing
### `buildCountryCityRows.test.ts`
- Empty region → `[]`.
- Missing region id → `[]`.
- RU pinned first when present in the region's countries.
- Within RU, MOW pinned first in the cities sort.
- Single-airport cities paired into rows of two.
- Multi-airport city on its own row with `city1Airports` populated.
- Odd-count cities: last row has `city2: null`.
- `countryName` only on the first row of each country block.
### `CityAutocomplete.test.tsx` (jsdom)
- Renders label and placeholder.
- Typeahead filters by 3-char city code (exact uppercase match).
- Typeahead filters by name substring (case-insensitive).
- Typeahead filters by airport code → resolves to city.
- Clear button resets to empty string and fires `onChange("")`.
- Popup-trigger button toggles popup open/closed.
- Outside-click closes popup.
- Selecting a city from the popup fires `onChange(code)` and closes popup.
- `error` prop renders tooltip and adds `has-error` class.
- External `value` change updates the displayed code label.
### `CityPickerPopup.test.tsx` (jsdom)
- Renders a tab button per region.
- Clicking a tab switches the active region.
- Clicking a city cell calls `onSelect(city.code)`.
- Clicking an airport cell in a multi-airport city calls `onSelect(airport.code)`.
- `selectedCode` applies `city-active` class to the matching cell.
- GPS button rendered only when `onLocate` is passed.
- Clicking GPS invokes `onLocate`.
### `OnlineBoardFilter.test.tsx` update
- Route tab renders `<CityAutocomplete>` instead of raw `<AutoComplete>`.
- Mock `CityAutocomplete` to capture props; assert `testIdPrefix`, `value`, `dictionaries`.
- Existing route-tab assertions remain green (via preserved `testIdPrefix` mappings).
### `FlightsMapFilter.test.tsx` update
- Both inputs render `<CityAutocomplete>`.
- Departure gets `onLocate` prop; arrival does not.
- Existing calendar + snap tests remain green.
## Success criteria
- `pnpm tsc --noEmit` clean.
- All new tests pass. Expected delta: ~30 new (~8 buildCountryCityRows + ~10 CityAutocomplete + ~6 CityPickerPopup + ~3 consumer integration × 2).
- Full suite green.
- Visual: onlineboard-start-desktop React screenshot matches Angular for the left-menu filter area (regional picker button visible next to each city input; popup renders the same tab set and grid layout on click).
## Open details resolved
- **Component location:** `src/ui/city-autocomplete/` — consistent with existing `src/ui/` convention.
- **GPS feature flag:** no new infra; FlightsMap departure passes `onLocate`, OnlineBoard omits it.
- **Time selector compact view:** pure SCSS + layout restructure, no new component.
- **Test-id continuity:** via `testIdPrefix` prop on `CityAutocomplete`, preserves existing selectors.