npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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-tr
import { 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 summary reports Kadıköy rather than TR-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. Merkez names 51 different districts in Türkiye. A flat list of district names is unusable; nested under its province, each one is unique again. decode falls back to names when codes are missing, so a payload that lost its codes still resolves — and resolves to the right Merkez.
  • 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);   // 24

Props

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