Coordinates Input
A latitude/longitude pair that reads decimal degrees or DMS in either field, re-prints between the two on a toggle, names the field that leaves ±90 / ±180, and splits a pasted pair across both inputs.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/coordinates-input.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "CoordinatesInput" component: a latitude /
longitude pair that reads decimal degrees and DMS (lucide-react for the clear
icon; no other runtime dependency).
Contract
- Export interface Coordinates { lat: number; lng: number } and
type CoordinateFormat = "dd" | "dms". forwardRef lands on the outer div;
props extend HTMLAttributes<HTMLDivElement> minus defaultValue / onChange.
- Value: controlled (value: Coordinates | null + onValueChange) or uncontrolled
(defaultValue). onValueChange fires with a Coordinates only when BOTH fields
parse, and with null the moment either stops — a half-entered pair is never
emitted as a partial object, and the same pair is never emitted twice (compare
against a ref holding the last emitted pair, read and written in the handler).
- Notation: controlled (format + onFormatChange) or uncontrolled (defaultFormat,
default "dd").
- Options: precision (DD decimals, default 5, about 1 m, trunc-clamped 0..8),
secondsPrecision (default 2, about 0.3 m, clamped 0..4), hemisphere
"auto" | "sign" | "letter" (default auto = sign in DD, letter in DMS; parsing
accepts both either way), showFormatToggle, labels {latitude, longitude},
label (the group's name), description, disabled, name (hidden input carrying
"lat,lng"), className.
Behavior
- Parsing is one pure function: normalise, tokenise, then fill slots.
* normalise maps typographic primes, curly quotes, the masculine ordinal, the
Unicode minus, brackets and non-breaking spaces onto ASCII, so text out of a
PDF, a wiki table or a GeoJSON snippet all arrive in one shape.
* the tokeniser emits numbers, unit marks (degree sign, prime, double prime,
and two apostrophes for seconds), hemisphere letters and separators; any
other character fails the parse outright.
* numbers fill degrees, minutes and seconds positionally, and an explicit unit
mark must match its slot. degrees = |d| + m/60 + s/3600, signed by the letter
(N/E +1, S/W -1) or by the minus. Number("-0") is -0 and -0 < 0 is false, so
read the sign off the text as well as off the number.
- Refusals are specific, and none of them rewrites the field: minutes or seconds
at 60 or more, decimals on anything but the smallest unit present (48.5° 30'
has two readings), a sign on minutes or seconds, two hemisphere letters, a
minus and an S together, a number after a suffix letter, more than three
numbers, one coordinate containing a separator.
- Two clocks for two kinds of wrong. A shape that is merely unfinished stays
quiet until blur or Enter, because another digit rescues a half-typed 48°51.
A range or hemisphere problem is shown while typing, because no digit rescues
91.5 or an E in the latitude field. Both keep the raw text — retyping a whole
coordinate to fix one digit is the worst thing this field can ask for.
- Range: |lat| <= 90, |lng| <= 180, and the message names the field
("Latitude must be between -90° and 90° — got 91.5°"). An E or W typed into
the latitude field is the same class of error, named the same way. A value
arriving through props is checked too: flagged, never silently accepted.
- Paste is a ladder of splitters, tried in order, each one only *proposing* a
cut; the first proposal whose two halves both parse wins:
1. an explicit separator — "48.8584, 2.2945"
2. the two hemisphere letters — 48°51'30"N 2°17'40"E, or N48.8584 E2.2945
3. an even number of whitespace words, cut down the middle — 48 51 30 2 17 40
Axes come from the letters when they are there, so "2.2945 E, 48.8584 N" still
lands the right way round, and from the latitude-first convention when they
are not — unless that reading is out of range while the swapped one is not,
which is how "151.2153, -33.8568" copied out of GeoJSON is rescued: it has
exactly one possible reading. Anything the ladder cannot read as two
coordinates is left to the browser, so pasting a single value into a single
field keeps working, caret and all. Range is not enforced during the split: an
out-of-range half still lands in its field, where the normal check names it.
- The toggle re-prints, it never re-enters. Settle any open draft first, then
regenerate both fields from the committed numbers in the new notation. DMS
rounding has to carry: 48.99999999° rounds to 59.999…" in the seconds, which
must become 49°00'00.00" and not 48°59'60.00".
- Keyboard map. ArrowUp / ArrowDown step the focused field by one unit of the
current notation (10^-precision degrees in DD, one arcsecond in DMS), commit
immediately, and step off whatever is on screen, including an unsettled draft.
Shift moves the next unit up: ×10 in DD, one second becomes one minute in DMS.
A step clamps at the axis limit and announces that it held. Enter settles a
dirty draft and consumes the press, so a surrounding form is never submitted
with text that has not been through the parser; a second Enter submits as
usual. Escape restores what the field held before the draft began, and stops
propagating so a surrounding dialog does not close on the same key. The DD/DMS
switch is a radiogroup with a roving tabindex: one tab stop for the group,
Arrow keys and Home/End move and select, focus follows the selection.
- ARIA contract. The root is role="group" named by the visible label. Each field
is a plain text input with a real <label htmlFor>, deliberately not
role="spinbutton" — the value is text (48°51'30"N), not a point on a number
line. aria-invalid marks only the offending side. aria-describedby points at
one always-mounted message line, which is *not* a live region because its text
follows every keystroke; a separate sr-only role="status" announces only the
events a screen reader would otherwise miss: a settled field, a step, a pasted
pair, a format switch.
- Inert state is readOnly + aria-disabled with a guard at the top of every
handler, never the native disabled attribute, so a focused field is not blurred
to <body> the moment it goes inert. The per-field clear button unmounts itself
as the field empties, so it focuses the input first and clears second.
- Cleanup: the paste highlight is the only timed thing here. Arm it with an
incrementing token so a second paste re-arms the timer from scratch, and clear
it in the effect cleanup, which also runs on unmount.
- Controlled sync: a parent that moves the value on its own (a map click, a form
reset) replaces both fields and their drafts; a parent that merely echoes back
what was just emitted must not, or every keystroke would reformat the text
under the caret. Compare the incoming value against the last emitted pair, not
against the previous prop. A controlled null falls back to the half-entered
state the component owns, which is exactly what "no complete pair" means.
Rendering & styling
- Header row: the group label and the DD/DMS radiogroup. Then a flex-wrap row of
two fields at min-w-40 each, so a container too narrow for both stacks them
instead of cutting the seconds off a value. Then the message line.
- Fields are h-9 rounded-md border-input shells with focus-within:border-ring
plus ring-ring/50, and border-destructive plus ring-destructive/30 when
invalid; the value is tabular-nums so digits do not jitter as they are typed.
- The checked radio is bg-primary / text-primary-foreground; labels, hints and
the clear icon are text-muted-foreground; refusals are text-destructive; a
pasted pair flashes border-primary plus bg-primary/5 for about 900 ms. Colour
transitions only, all switched off under prefers-reduced-motion — the flash is
decoration, the values are committed with or without it. Semantic tokens only,
so dark mode comes free.
- inputMode="text", not "decimal": a coordinate carries a degree sign, primes
and a hemisphere letter, and a numeric keypad hides every one of them.
Customization levers
- Precision: precision drives the DD decimals (5 is about 1 m, 7 about 1 cm) and
secondsPrecision the DMS seconds. Both also define the arrow-key step, so a
coarser field steps coarser without a second prop.
- Notation set: the format options are a two-row table. Add a third row (UTM,
MGRS, an internal grid) by giving it a format/parse pair — the toggle, the
secondary readout and the announcements all read from that table.
- Hemisphere style: hemisphere="letter" prints 48.858° N in decimal degrees for
survey-style forms; "sign" keeps -33.857 everywhere for engineering ones.
Parsing is unaffected, so both dialects stay typeable either way.
- Bounds: one table holds ±90 / ±180. Swap it for a bounding box to keep entries
inside one country and the same message names the field. For wrap-around
longitudes, replace the clamp in the step helper with a modulo; the parser
needs no change.
- Density and chrome: h-9 / text-sm / gap-2 are the sizing knobs;
showFormatToggle={false} plus labels strips and renames the chrome for an
inspector sidebar. Drop the secondary readout when a map beside the field
already shows the other notation.
- Submission: name emits one hidden "lat,lng" input, which the paste splitter
reads back verbatim. Swap it for two hidden inputs, or drop it entirely and
drive a form library from onValueChange.Concepts
- Emitted only when both parse — the pair is the unit of truth, so the callback never hands back half a point. Every keystroke re-decides it: a field that stops parsing takes the whole pair to
null, and an identical pair is never emitted twice, which keeps a controlled parent from ping-ponging. - Hemisphere by sign or by letter —
-33.8568and33.8568 Sare the same number, and the letter carries something the sign does not: which axis it belongs to. That is what lets a reversed paste, or anEtyped into the latitude field, be recognised instead of quietly accepted. - Two clocks for two kinds of wrong — an unfinished shape waits for blur or Enter, because the next digit may complete it; a value outside ±90 / ±180 is reported as it is typed, because no further digit will rescue it. Neither rewrites what was typed.
- The splitter ladder — a pasted pair is cut by an explicit separator, by its two hemisphere letters, or straight down the middle of an even run of numbers, whichever produces two halves that both parse. When neither half names its axis, latitude comes first — unless only the swapped reading fits ±90 / ±180, in which case there is exactly one possible reading and it wins.
- Re-print, never re-enter — the toggle is a view change, not a data change: drafts are settled first and both fields are regenerated from the committed numbers, so switching notation back and forth is lossless. The rounding carry that makes
48°59'60"impossible lives in the same place. - One message line, one live region — the visible line follows every keystroke and is wired through
aria-describedby; a separate sr-onlyrole="status"announces only settled commits, steps, pasted pairs and format switches, so a screen reader hears the verdict once instead of on every key.
Calculator Input
A number field that does arithmetic — type =2+3*4 or plain 2+3*4 and it commits 14, parsed by a hand-rolled parser that never calls eval.
Meeting Planner
Find a time across time zones — participant rows over one shared 24-hour track, a draggable column that reads the same instant in every zone, hour snapping with Shift for half hours, and working hours derived from Intl.