Source: https://monarchui.dev/components/location-fields/

Components · Location

# LocationFields

Country, region, city and postal code as one composite, with the data source injected.

@rookcom/monarch-react

Component

## Controlled address

## Usage examples

### Subset and failure

## Purpose

The address part every form re-implements: a searchable country, a region list that uses each country’s own word for it, a server-searched city that can also be typed freely, and a postal code. The lists come from an adapter the product provides.

### Use when

- Any form that takes a postal or business address.
- The product has a geographic data source and wants stored codes (country, region, city ids) beside the text.

### Avoid when

- Only a country is needed: a single Combobox of countries is simpler.
- A street-level autocomplete from a maps provider is wanted; that is a different, provider-specific control.

## Design principles

- No data, no network: Monarch ships no geographic lists and makes no requests. `adapter` is the only source.
- Codes and text together: the value stores each code beside its label, so the server can verify and canonicalise it.
- Local words: the region label comes from the adapter ("Province", "Emirate"), never a hard-coded "State".
- Never a dead end: a city missing from the data can be typed, a country without regions gets a free-text field, and a failed list offers Try again.

## API reference

```tsx
import { LocationFields } from '@rookcom/monarch-react';
```

| Export | Kind | Rendering | Root class |
| --- | --- | --- | --- |
| `LocationFields` | component | client | `.mn-location-fields` |

Renders a `<fieldset class="mn-location-fields">` with a Field per part. The ref goes to the fieldset.

`LocationAdapter` has `countries(signal)`, optional `regions(country, signal)` and optional `cities({ country, region, query }, signal)`. Omit `regions` or `cities` for free-text fields.

Generated from the TypeScript declarations of `packages/react/src/components/geo/location-fields`.

## Properties

`onValueChange(value, changed)` receives the whole `LocationValue` and which field changed. Changing the country resets every other part; changing the region resets the city.

### `LocationFields`

Country, region, city and postal code as one composite. Lists come from an injected adapter, so the product owns the data and the requests. Picking a country loads its regions under the country's own word; cities are searched as the user types, can be typed freely, and fill in their region.

| Prop | Type | Description |
| --- | --- | --- |
| `adapter` required | `LocationAdapter` | The data source. Required. |
| `allowCustomCity` | `boolean` | Offer the typed text as a city when it is not in the list. Default true. |
| `children` | `ReactNode` |  |
| `cityDebounce` | `number` | Milliseconds without typing before cities are searched. Default 250. |
| `cityMinQuery` | `number` | Letters needed before cities are searched when no region is chosen. Default 2. |
| `className` | `string` |  |
| `defaultValue` | `Partial<LocationValue>` | Initial address when uncontrolled. |
| `disabled` | `boolean` |  |
| `errors` | `Partial<Record<LocationField, ReactNode>>` | Error messages per field; a field with a message is marked invalid. |
| `fields` | `LocationField[]` | Which fields to show, in order. Default all four. |
| `id` | `string` |  |
| `idPrefix` | `string` | Prefix for the field ids, so labels stay stable and two address forms on a page never clash. |
| `labels` | `Partial<Record<LocationField, string>>` | Override field labels. The region label is replaced by the adapter's word for the country, when given. |
| `legend` | `ReactNode` | Visible group label. Without it, give the fieldset an `aria-label` if the address needs a name. |
| `messages` | `Partial<LocationFieldsMessages>` |  |
| `onValueChange` | `((value: LocationValue, changed: LocationField) => void)` | Called with the whole new value and the field that changed. Changing the country clears the rest. |
| `required` | `LocationField[]` | Fields that must be filled; they get `required` and the label asterisk. |
| `value` | `LocationValue` | The address (controlled). |

## Variants

| Prop | Values | Description |
| --- | --- | --- |
| `fields` | `'('country' \| 'region' \| 'city' \| 'postalCode')[]'` | Which parts to show, in order. Default all four. |
| `allowCustomCity` | `'true'``'false'` | Offer the typed text as a city when it is not in the list. Default true. |

## States

| State | Exposed as | Behaviour |
| --- | --- | --- |
| Loading | – | A list is loading: the combobox shows a spinner and "Loading…". |
| Waiting for a country | – | Region and city are disabled with the placeholder "Choose a country first". |
| Load failed | – | The field is replaced by a `role="alert"` message with a Try again button. |
| Invalid | `data-invalid on the Field, aria-invalid="true" on the control` | A field with a message in `errors` shows it under the control. |
| Disabled | `disabled on the fieldset` | Every part is disabled. |

## Interaction behaviour

- Countries load once on mount. Choosing a country loads its regions and clears the region, city and postal code.
- A country with no regions in the data gets an optional free-text region field.
- Cities are searched after `cityDebounce` ms (default 250) once `cityMinQuery` letters are typed (default 2), or listed at once when a region is chosen. Superseded requests are aborted.
- Choosing a city before a region fills in the region the city belongs to.
- A city not in the list is offered as "Not in the list: use as typed" and stored with `cityId: null`.
- Controlled with `value` + `onValueChange`, or uncontrolled with `defaultValue`.

## Keyboard support

| Keys | Action |
| --- | --- |
| Tab | Moves through country, region, city and postal code in order. |
| ArrowDown / ArrowUp | Open and move through a list (Combobox). |
| Enter | Choose the highlighted option. |
| Escape | Close the list and restore the chosen text. |

Each list is a Monarch Combobox and has its full keyboard model.

## Accessibility

Role

group (fieldset) of comboboxes and text inputs

Accessible name

The

legend

, or an

aria-label

on the fieldset; each part has its own visible label.

- Each part is a Field, so labels, required state and errors are wired with `htmlFor`, `aria-required`, `aria-invalid` and `aria-describedby`.
- Load failures are announced with `role="alert"` and the retry is a real button.
- The region label follows the country, so screen reader users hear the same word sighted users see.
- Free-text parts use `autocomplete` (`address-level1`, `address-level2`, `postal-code`).

## Responsive behaviour

- An auto-fit grid: two columns from about 30rem, one column below, down to 320px.

## Theme support

- Built from Combobox, Input, Field and Button, so it follows light, dark and more-contrast themes and the product accent.
- The failure message uses the danger semantic tokens.

## Do and don't

### Do

- Pass the AbortSignal to your requests so superseded searches stop.
- Store the codes and verify them on the server when the form is saved.
- Return each country’s own word for its regions from `regions`.

### Don't

- Do not bundle a copy of geographic lists into the app to avoid the adapter; it drifts out of date.
- Do not require a region for countries that have none.

## Related components

- [Combobox](https://monarchui.dev/components/combobox/): Each list part; use it alone for a single country select.
- [Field](https://monarchui.dev/components/form-field/): Label, required and error wiring for each part.
- [Input](https://monarchui.dev/components/text-field/): The free-text parts.

## Dependencies

| Kind | Depends on |
| --- | --- |
| npm packages | `react` |
| Monarch components | [Button](https://monarchui.dev/components/button/), [Combobox](https://monarchui.dev/components/combobox/), [Field](https://monarchui.dev/components/form-field/), [Input](https://monarchui.dev/components/text-field/) |

## Testing coverage

- Loads countries, then regions under the adapter’s word; choosing reports the value and the changed field.
- Region and city wait for a country.
- Cities are searched after the minimum letters with one debounced request, and a city fills its region.
- A chosen region lists its cities at once.
- A city typed freely is kept with `cityId: null`.
- A country without regions gets a free-text region.
- A failed list shows an alert and Try again reloads it.
- Changing the country clears the other parts (controlled).
- Required, errors, custom labels and a subset of fields; disabled; server render calls no adapter method.

| Check | Covered |
| --- | --- |
| Axe in jsdom | Yes |
| Server rendering | Yes |
| Browser suites | Visual baselines: /__gallery; Cross-browser axe: /components/location-fields |

Tests: `packages/react/src/components/geo/location-fields`

## All example code

### Controlled address

```tsx
import { useState } from 'react';
import { LocationFields, type LocationAdapter, type LocationCityOption, type LocationValue } from '@rookcom/monarch-react';

const REGIONS: Record<string, { label: string; options: { value: string; label: string }[] }> = {
  CA: {
    label: 'Province',
    options: [
      { value: 'CA-BC', label: 'British Columbia' },
      { value: 'CA-ON', label: 'Ontario' },
      { value: 'CA-QC', label: 'Quebec' },
    ],
  },
  IN: {
    label: 'State / Union Territory',
    options: [
      { value: 'IN-KA', label: 'Karnataka' },
      { value: 'IN-MH', label: 'Maharashtra' },
      { value: 'IN-TN', label: 'Tamil Nadu' },
    ],
  },
  AE: {
    label: 'Emirate',
    options: [
      { value: 'AE-AZ', label: 'Abu Dhabi' },
      { value: 'AE-DU', label: 'Dubai' },
    ],
  },
  SG: { label: 'Region', options: [] },
};

const CITIES: LocationCityOption[] = [
  { value: 'ca-van', label: 'Vancouver', region: { value: 'CA-BC', label: 'British Columbia' } },
  { value: 'ca-tor', label: 'Toronto', region: { value: 'CA-ON', label: 'Ontario' } },
  { value: 'ca-ott', label: 'Ottawa', region: { value: 'CA-ON', label: 'Ontario' } },
  { value: 'ca-mtl', label: 'Montreal', region: { value: 'CA-QC', label: 'Quebec' } },
  { value: 'in-blr', label: 'Bengaluru', region: { value: 'IN-KA', label: 'Karnataka' } },
  { value: 'in-bom', label: 'Mumbai', region: { value: 'IN-MH', label: 'Maharashtra' } },
  { value: 'in-maa', label: 'Chennai', region: { value: 'IN-TN', label: 'Tamil Nadu' } },
  { value: 'ae-dxb', label: 'Dubai', region: { value: 'AE-DU', label: 'Dubai' } },
  { value: 'ae-auh', label: 'Abu Dhabi', region: { value: 'AE-AZ', label: 'Abu Dhabi' } },
];

const wait = <T,>(value: T, signal: AbortSignal, ms = 0) =>
  new Promise<T>((resolve, reject) => {
    const t = setTimeout(() => resolve(value), ms);
    signal.addEventListener('abort', () => {
      clearTimeout(t);
      reject(new DOMException('Aborted', 'AbortError'));
    });
  });

const demoAdapter: LocationAdapter = {
  countries: (signal) =>
    wait(
      [
        { value: 'CA', label: 'Canada' },
        { value: 'IN', label: 'India' },
        { value: 'SG', label: 'Singapore' },
        { value: 'AE', label: 'United Arab Emirates' },
      ],
      signal,
    ),
  regions: (country, signal) => wait(REGIONS[country] ?? { options: [] }, signal),
  cities: ({ country, region, query }, signal) =>
    wait(
      CITIES.filter(
        (c) =>
          c.value.startsWith(country.toLowerCase()) &&
          (!region || c.region?.value === region) &&
          c.label.toLowerCase().includes(query.toLowerCase()),
      ).map((c) => (region ? c : { ...c, description: c.region?.label ?? '' })),
      signal,
      300,
    ),
};

function ControlledDemo() {
  const [value, setValue] = useState<LocationValue>({
    country: 'CA',
    region: 'CA-ON',
    regionName: 'Ontario',
    city: 'Toronto',
    cityId: 'ca-tor',
    postalCode: '',
  });
  return (
    <div style={{ display: 'grid', gap: 12, width: '100%', maxWidth: 640 }}>
      <LocationFields
        adapter={demoAdapter}
        legend="Billing address"
        value={value}
        onValueChange={setValue}
        required={['country', 'city']}
      />
      <pre style={{ margin: 0, fontSize: 12, overflowX: 'auto' }}>
        {JSON.stringify(value, null, 2)}
      </pre>
    </div>
  );
}

<ControlledDemo />
```

### Subset and failure

```tsx
import { LocationFields, type LocationAdapter, type LocationCityOption } from '@rookcom/monarch-react';

const REGIONS: Record<string, { label: string; options: { value: string; label: string }[] }> = {
  CA: {
    label: 'Province',
    options: [
      { value: 'CA-BC', label: 'British Columbia' },
      { value: 'CA-ON', label: 'Ontario' },
      { value: 'CA-QC', label: 'Quebec' },
    ],
  },
  IN: {
    label: 'State / Union Territory',
    options: [
      { value: 'IN-KA', label: 'Karnataka' },
      { value: 'IN-MH', label: 'Maharashtra' },
      { value: 'IN-TN', label: 'Tamil Nadu' },
    ],
  },
  AE: {
    label: 'Emirate',
    options: [
      { value: 'AE-AZ', label: 'Abu Dhabi' },
      { value: 'AE-DU', label: 'Dubai' },
    ],
  },
  SG: { label: 'Region', options: [] },
};

const CITIES: LocationCityOption[] = [
  { value: 'ca-van', label: 'Vancouver', region: { value: 'CA-BC', label: 'British Columbia' } },
  { value: 'ca-tor', label: 'Toronto', region: { value: 'CA-ON', label: 'Ontario' } },
  { value: 'ca-ott', label: 'Ottawa', region: { value: 'CA-ON', label: 'Ontario' } },
  { value: 'ca-mtl', label: 'Montreal', region: { value: 'CA-QC', label: 'Quebec' } },
  { value: 'in-blr', label: 'Bengaluru', region: { value: 'IN-KA', label: 'Karnataka' } },
  { value: 'in-bom', label: 'Mumbai', region: { value: 'IN-MH', label: 'Maharashtra' } },
  { value: 'in-maa', label: 'Chennai', region: { value: 'IN-TN', label: 'Tamil Nadu' } },
  { value: 'ae-dxb', label: 'Dubai', region: { value: 'AE-DU', label: 'Dubai' } },
  { value: 'ae-auh', label: 'Abu Dhabi', region: { value: 'AE-AZ', label: 'Abu Dhabi' } },
];

const wait = <T,>(value: T, signal: AbortSignal, ms = 0) =>
  new Promise<T>((resolve, reject) => {
    const t = setTimeout(() => resolve(value), ms);
    signal.addEventListener('abort', () => {
      clearTimeout(t);
      reject(new DOMException('Aborted', 'AbortError'));
    });
  });

const demoAdapter: LocationAdapter = {
  countries: (signal) =>
    wait(
      [
        { value: 'CA', label: 'Canada' },
        { value: 'IN', label: 'India' },
        { value: 'SG', label: 'Singapore' },
        { value: 'AE', label: 'United Arab Emirates' },
      ],
      signal,
    ),
  regions: (country, signal) => wait(REGIONS[country] ?? { options: [] }, signal),
  cities: ({ country, region, query }, signal) =>
    wait(
      CITIES.filter(
        (c) =>
          c.value.startsWith(country.toLowerCase()) &&
          (!region || c.region?.value === region) &&
          c.label.toLowerCase().includes(query.toLowerCase()),
      ).map((c) => (region ? c : { ...c, description: c.region?.label ?? '' })),
      signal,
      300,
    ),
};

const failingAdapter: LocationAdapter = {
  countries: () => Promise.reject(new Error('Service unavailable')),
};

<div style={{ display: 'grid', gap: 24, width: '100%', maxWidth: 640 }}>
  <LocationFields
    adapter={demoAdapter}
    aria-label="Office location"
    fields={['country', 'city']}
    defaultValue={{ country: 'IN' }}
  />
  <LocationFields
    adapter={failingAdapter}
    aria-label="Shipping address"
    fields={['country']}
  />
</div>
```
