Components · Location
LocationFields
Country, region, city and postal code as one composite, with the data source injected.
Controlled address
{
"country": "CA",
"region": "CA-ON",
"regionName": "Ontario",
"city": "Toronto",
"cityId": "ca-tor",
"postalCode": ""
}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 />Usage examples
Subset and failure
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>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.
adapteris 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
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
cityDebouncems (default 250) oncecityMinQueryletters 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 withdefaultValue.
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 anaria-labelon 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-invalidandaria-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
Dependencies
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