GeoSelector
Pick geographic regions from a map and a multi-Selector, kept in lockstep. GeoSelector is agnostic — it renders whatever geography a GeoSource config feeds it (US states today; counties, countries, regions later as opt-in data packs). It composes the canonical Selector for the field and renders the map with d3-geo — no react-simple-maps, so it stays clean under React 19. Semantic per-region tints turn the map into a compliance pass/fail overlay.
Two layers. The mechanism — @halo-compliance/ui/geo — is the agnostic GeoSelector + the GeoSource type; it knows nothing about US states. The data — @halo-compliance/ui/geo/us-states — is an opt-in pack: the region list, the FIPS→postal mapping, and the TopoJSON URL. Apps that never render a map import neither, and the ~110KB TopoJSON is fetched at render, never bundled. New geographies become new packs, never new components.
Demo
Click a state on the map or pick it in the field — they stay synced. The field is the canonical multi-Selector (typeahead, removable chips, the overflow deck), with a running count and Select all / Clear above it; the search even matches a region’s code (try “AZ”). The map is keyboard-navigable (Tab in, then arrows + Enter/Space) on a pointer device, and a read-only summary on phones. The second demo layers a status overlay — tone hints, centroid glyphs, and a validator — on the same control.
Select the states you operate in to scope the report.
How it works
A thin composition: the Selector owns the field, d3-geo owns the map, the parent owns the value. No internal coupling between the two halves.
- Field
- The canonical multi-Selector.
Same component documented at /selector — chips, typeahead, the fan deck, full a11y. Fed source.regions as its options; the typeahead matches each region’s keywords too, so a code like “AZ” finds Arizona.
- Toolbar
- Count + Select all / Clear.
A running "N of M" count and silver-blue Select all / Clear buttons sit above the field. Bulk select is well-defined here (unlike a generic async Selector) because the GeoSource enumerates the whole region set.
- Map
- d3-geo + topojson-client.
topojson-client decodes the TopoJSON to GeoJSON features; geoPath + the source projection turn each into an SVG path. No react-simple-maps (it excludes React 19 and relies on function-component defaultProps); these are React-agnostic functions.
- Sync
- Both halves read the same value and call the same onChange — clicking a region and picking it in the field are one parent-owned update. No two-way wiring to drift.
- GeoSource
- The config that makes it agnostic: a TopoJSON (URL or object), the object key, a d3 projection factory, a feature→region-id mapper, and the selectable region list.
Props
- source
GeoSourceThe geography config. Use the us-states pack, or author your own for a different geography.
- value / onChange
string[]Controlled, multi-select. The ids are whatever your GeoSource regions use (postal codes for us-states).
- regionColors
Record<string, 'affirm' | 'caution' | 'critical'>Per-region semantic tint, keyed by region id. The compliance pass/fail overlay; pairs with the severity palette (ADR-0017).
- hintUnselected
booleanOff by default. When on, regions that carry a regionColors tone but are not selected show a faint wash of it — a "this set is good/bad" hint visible before selection. Configurable, not forced; no effect without regionColors.
- validator
GeoValidatorA custom rule over the selection: { rule, validate, message?, severity? }. The rule is always disclosed as helper text under the field; a violation escalates the tone and either blocks the form (severity "blocking" → invalid) or just warns ("warning" → caution).
- regionStatusLabels
Record<string, string>Accessible status text per region, appended to its name (e.g. "California, failing") — screen-reader parity for the colour overlay, whose meaning is otherwise colour-only. Pair with a non-colour visual cue for colour-blind sighted users (ADR-0017).
- ariaLabel / ariaLabelledBy
stringAccessible name for both the field and the map group. Always provide one — there is no built-in label.
- disabled / invalid
booleanMirror the Selector field states; disabled also dims the map and makes regions inert (role="img").
Usage
import { GeoSelector } from '@halo-compliance/ui/geo'
import { usStates } from '@halo-compliance/ui/geo/us-states'
<GeoSelector
ariaLabel="Operating states"
source={usStates}
value={states} // string[] of postal codes
onChange={setStates}
/><GeoSelector
ariaLabel="State compliance status"
source={usStates}
value={states}
onChange={setStates}
regionColors={{
CA: 'critical', // rust — failing
TX: 'caution', // copper — needs review
NY: 'affirm', // patina — compliant
}}
/>import { geoAlbersUsa } from 'd3-geo'
import type { GeoSource } from '@halo-compliance/ui/geo'
// A new geography is a new pack — not a new component.
const usCounties: GeoSource = {
geography: '/geo/counties-10m.json',
objectKey: 'counties',
projection: () => geoAlbersUsa(),
featureToId: (f) => String(f.id),
regions: COUNTY_OPTIONS, // { value, label }[]
}<GeoSelector
ariaLabel="State compliance"
source={usStates}
value={states}
onChange={setStates}
regionColors={STATUS_TONES} // affirm | caution | critical, per region
hintUnselected // toned-but-unselected → faint wash + glyph
regionStatusLabels={STATUS_WORDS} // SR text → "California, failing"
validator={{
rule: 'Select the states you operate in.',
validate: (v) => v.length >= 1,
severity: 'warning', // 'blocking' → invalid · 'warning' → caution
}}
/>// Form-bound: GeoSelectorField ships from /geo, validation comes from the schema.
import { GeoSelectorField } from '@halo-compliance/ui/geo'
<form.AppField name="states">
{() => <GeoSelectorField label="Operating states" source={usStates} />}
</form.AppField>Accessibility
The map is a first-class control, not decoration — it carries the same selection as the field, reachable without a mouse.
- Roles — the map is a group of role="checkbox" regions (role="img" when disabled), each with aria-checked and an aria-label naming the region.
- Keyboard — roving tabindex into the map; arrows (and Home/End) move between regions in list order; Enter/Space toggles. The field keeps its own combobox keyboard model.
- Colour is never alone — a toned region carries a shape glyph at its centroid (a check, a triangle, or a cross) so status survives red-green colour blindness (ADR-0017), and regionStatusLabels speaks that status in the region’s accessible name. Selection itself rides aria-checked and the chips. Neither status nor selection ever depends on hue.
- Names on demand — not everyone has regions memorised by location, so each one surfaces its name in a label on pointer hover and keyboard focus — the sighted mirror of the aria-label screen readers already hear.
- Read-only on small screens — below 600px the map is too small to tap accurately, so it becomes a non-interactive summary (role="img", hidden from assistive tech) and the field is the sole control. Every region stays reachable — through the field, not lost.
- Validation discloses its rule — a validator’s rule shows as helper text under the field, associated via aria-describedby; a violation escalates the tone and either blocks the form (invalid) or warns (caution).
Related
- /selector — the multi-select field GeoSelector composes; everything about chips, typeahead, and the deck lives there.
- /colors — the status palette (success green / warning amber / danger red) the regionColors overlay draws from. The metal names — patina / copper / rust — belong to the proposed system.
- /tokens — the brown-text, gold, and status tokens the map fills resolve to.