react-map-input
v0.1.0
Published
A form input whose UI is a map. Select administrative regions and sub-regions by clicking, holding and drilling into a real country map.
Maintainers
Readme
react-map-input
A form input whose UI is a map.
Render a country with its provinces and districts, hold a province to take all of it, tap to zoom in and pick individual districts. You get back a small, canonical value that submits like any other form field.
Ships with Türkiye — 81 provinces (il), 973 districts (ilçe).
npm install react-map-input @react-map-input/data-trimport { GeoInput } from 'react-map-input';
import { turkey } from '@react-map-input/data-tr';
<GeoInput pack={turkey} name="serviceAreas" onChange={(value, summary) => console.log(value, summary)} />The value
type GeoSelection = {
regions: string[]; // fully-selected provinces — implies every district
subregions: string[]; // individual districts inside partially-selected provinces
};It is canonical by construction. Select every district of a province and it collapses to
{ regions: ['TR-34'], subregions: [] }; deselect one and it expands back into the other 38.
One selection state always serialises to exactly one string, so equality is a string compare and
form dirty-checking behaves.
onChange(value, summary) also hands you a derived view — per-province full / partial /
none, resolved names, and counts — so you never recompute it.
Gestures
| Gesture | Result |
|---|---|
| Hold 500 ms on a province | Select the whole province (a ring fills at the pointer) |
| Release before 500 ms | Zoom into that province's districts |
| Drag more than 10 px | Cancel the hold |
| Esc, or tap the sea | Zoom back out |
| Tab / Enter / Space / arrows | Move / open / select from the keyboard |
Remap any of it with gestures={{ longPressMs, longPress, tap, moveTolerance }}.
Hovering names whatever is under the pointer — a province at country level, a district once you
have zoomed in. Districts are labelled with their province too, because district names are not
unique: Merkez alone identifies 51 different places, Merkez · Kırıkkale identifies one. During a
hold the label names the shape being held, which on touch is the only label there is.
Touch is handled properly: the OS context menu is suppressed, text selection and page panning are disabled over the map, and the synthetic click after a completed hold is swallowed so it cannot fire the tap action as well.
Border levels
Three levels are drawn, each as an independent stroke-only layer painted over the fills rather than as a stroke on the shapes themselves — so each level's weight, colour and dash pattern is independent of the others and of the selection colour underneath.
| Level | Default style | Shown | |---|---|---| | Group — geographic region (bölge) | thick, slate | always | | Region — province (il) | medium, solid | always | | Subregion — district (ilçe) | thin, dashed | once zoomed in |
Groups are purely visual and never selectable. Their outlines are dissolved from the same topology as the provinces, so a geographic-region border always follows province borders exactly — it can never cut through the middle of one. The build refuses to emit a pack where a province belongs to no group or to two.
<GeoInput pack={turkey} borders={{ group: false }} />Restyle any level through CSS custom properties:
.rmi-root {
--rmi-group-border: #6b7a8d;
--rmi-group-border-width: 2.25;
--rmi-group-border-dash: none;
--rmi-subregion-border-dash: 2.5 2; /* and -region-* likewise */
}Every stroke uses vector-effect: non-scaling-stroke, so weights stay constant as the map zooms.
Zoomed in
While a region is open, everything outside it switches to a muted palette — backdrop, fills and both border levels — and the opened region gets its own outline drawn last, so it reads as "you are here". Neighbouring regions stay clickable, so you can jump straight from one detail view to another.
Recessing them has to change the fill, not just the opacity: the land backdrop is painted in
--rmi-land, the same colour as an unselected region, so fading one over the other composites
--rmi-land onto --rmi-land and changes nothing. Opacity is left to do the one job it is good at
— fading the selection colours, which genuinely differ from what is behind them. The result is
three distinguishable states at once: the opened region at full strength, selected neighbours as
faded blue, everything else recessive.
.rmi-root {
--rmi-land-muted: #e8ecf1;
--rmi-region-border-muted: #dfe5ec;
--rmi-group-border-muted: #b9c4d0;
--rmi-focus-outline: #3d4a5c;
--rmi-focus-outline-width: 2.75;
}Forms
Pass name and a hidden input carries the serialised value — plain HTML forms and FormData work
with no adapter:
<form onSubmit={(e) => { e.preventDefault(); new FormData(e.currentTarget).get('serviceAreas'); }}>
<GeoInput pack={turkey} name="serviceAreas" />
</form>Controlled (value + onChange) and uncontrolled (defaultValue) both work. For a completely
custom UI, useGeoSelection() exposes the whole model with no DOM.
Loading a saved value back
Pass a stored value straight back in — this is the edit-an-existing-record path, and it works on first paint:
<GeoInput pack={turkey} defaultValue={JSON.parse(row.serviceAreas)} />Whole provinces render selected immediately, and a province with individually-picked districts renders as partial, both before any district data has loaded. Two details make that hold:
- Codes are never discarded just because they cannot yet be verified. Normalisation keeps a district code whose province has not loaded — it can only be disproved once the real list arrives. A value restored from a database survives the first render intact.
- The districts a value references are fetched automatically, one small chunk per province
actually named, so
summaryreportsKadıköyrather thanTR-34-kadikoy. Provinces selected whole need no fetch at all, since their count is already known.
Talking to a backend
Your backend has its own identifiers, or keys on names, or wants the parent stored beside the child. A codec is that translation, and it stays out of the component:
import { GeoInput, nestedCodec } from 'react-map-input';
<GeoInput pack={turkey} name="areas" codec={nestedCodec} />[
{ "code": "TR-34", "name": "İstanbul", "all": true, "subregions": [] },
{ "code": "TR-06", "name": "Ankara", "all": false,
"subregions": [{ "code": "TR-06-cankaya", "name": "Çankaya" }] }
]Reading one back is the mirror image:
<GeoInput pack={turkey} defaultValue={nestedCodec.decode(fromBackend, { pack: turkey, index })} />Prefer the nested shape for anything that leaves the browser. It is not just tidier — it is the only one that survives the trip:
- Names stop being ambiguous.
Merkeznames 51 different districts in Türkiye. A flat list of district names is unusable; nested under its province, each one is unique again.decodefalls back to names when codes are missing, so a payload that lost its codes still resolves — and resolves to the rightMerkez. - Decoding needs no global scan. The parent travels with the child, so a stored value reads back without loading all 81 provinces to discover who owns a district.
- A partially-selected province still appears, carrying only the districts actually chosen — usually exactly what a join table wants.
| Codec | Shape | Use it for |
|---|---|---|
| compactCodec (default) | { regions: [], subregions: [] } | Smallest; needs no lookups |
| nestedCodec | array of regions with nested subregions | Anything crossing the wire |
| namesCodec | { "İstanbul": "*", "Ankara": ["Çankaya"] } | Backends keyed on names |
Write your own by implementing SelectionCodec<T> — two functions, encode and decode.
Your own IDs
Packs carry arbitrary attributes, so a backend key is just another meta field. The Türkiye pack
already exposes plateCode on provinces and districtId on districts:
const rows = (await resolveSelection(value, turkey)).subregions
.map((d) => ({ districtId: d.meta.districtId, name: d.name }));Getting the districts back out
A value is deliberately compact — one fully-selected province stands in for all of its districts —
so value alone cannot tell you which 39 districts the user picked. Persisting to a join table or
calling an API usually needs the flat list, and getting there means loading provinces that were
never opened:
import { resolveSelection } from 'react-map-input';
const { regions, subregions, codes } = await resolveSelection(value, turkey);
// codes → ['TR-34-adalar', 'TR-34-arnavutkoy', …]describeSelection(value, pack) gives a short human label instead (İstanbul, Ankara +3 more).
Validation
<GeoInput pack={turkey} name="areas" required limits={{ maxRegions: 3 }}
onLimitExceeded={(attempted, limits) => toast(`At most ${limits.maxRegions}`)} />required blocks submission while nothing is selected, through the browser's own constraint
validation — no adapter. (It renders a visually-hidden text input rather than type="hidden",
because hidden inputs are barred from validation entirely.)
Internationalisation
A pack localises its nouns through meta.labels — province/district, il/ilçe. The sentences around
them come from messages:
import { GeoInput, trMessages } from 'react-map-input';
<GeoInput pack={turkey} messages={trMessages} />
// or override just one
<GeoInput pack={turkey} messages={{ back: () => 'Geri' }} />The map itself has no visible text; this covers what screen readers announce.
Bulk selection
The pack carries the source data's attributes, so no extra lookup table is needed:
import { regionsWhere } from 'react-map-input';
regionsWhere(turkey, (r) => r.meta.region.en === 'Aegean'); // 8
regionsWhere(turkey, (r) => r.meta.isCoastal); // 28
regionsWhere(turkey, (r) => r.meta.population > 1_000_000); // 24Props
| Prop | Description |
|---|---|
| pack | The country data pack |
| value / defaultValue / onChange | Controlled or uncontrolled selection |
| name / form / required | Hidden-input form integration |
| codec / serialize | Shape written to the hidden input (see backend section) |
| mode | 'multi' (default) or 'single' |
| limits | { maxRegions, maxSubregions } |
| disabled / readOnly / disabledRegions / disabledSubregions | Constraints |
| gestures | Gesture table (see above) |
| drilledRegion / onDrillIn / onDrillOut | Control the drill-down externally |
| borders | { group, region, subregion } — switch each border level on or off |
| messages | Override the strings the accessible layer speaks (see i18n below) |
| onLimitExceeded | Fires with the attempted value when limits refuses a change |
| showTooltip / renderTooltip | Name popover on hover and during a hold (on by default) |
| theme | 'light' / 'dark'; otherwise follows an ancestor data-theme, then the OS |
| zoomDurationMs / zoomPadding | Zoom animation |
How it works
Geometry is projected at build time, not in the browser, so the runtime ships with zero
dependencies — no d3, no topojson, no projection maths. It renders <path d="…"> and animates a
viewBox.
Provinces and districts live in one shared coordinate space, which is what makes drilling in a
plain viewBox interpolation rather than a re-projection. Province outlines are produced by
dissolving districts after simplification, so a province border is exactly the union of its
districts' borders at every zoom level.
Districts load per province — the full country's districts are 527 KB, but you only ever fetch the one province you opened (median 5.6 KB, ~2 KB gzipped). The provinces layer is 59 KB gzipped.
Accessibility does not rely on interactive SVG, which screen readers handle inconsistently. The map
is aria-hidden and a visually-hidden parallel list of real <button role="checkbox"> elements
carries the semantics, focus order and keyboard interaction from the same state.
Other countries
@react-map-input/geopack turns any pair of admin-boundary GeoJSON files into a pack. Türkiye is just its first consumer.
Licence
Code is MIT. The Türkiye geodata is a separate package under ODbL 1.0 — see
@react-map-input/data-tr.
