# 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.
