location-picker / site
llms.txt81 starsChanged 17 days agoMITView on GitHub
Form-ready map location picker: pan a map under a fixed pin to pick one latitude/longitude. Web component + class, keyless OpenStreetMap by default, Google and MapLibre optional. Generated by scripts/build-llms-full.mjs from the README and docs/ of https://github.com/cyphercodes/location-picker. Short version: https://cyphercodes.github.io/location-picker/llms.txt Source: https://github.com/cyphercodes/location-picker/blob/master/README.md Let people pick an exact spot on a map, and get lat,lng in your form. Form-ready map location picker: pan a map under a fixed pin to pick one latitude/longitude. Web component + class, keyless OpenStreetMap by default,…
- Reads credentials
- Installs packages
We split this llms-full.txt into the rules it gives an agent
27 rules. About 0 are common, 6 are distinctive, and 21 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈6
Distinctive · estimate
This author's own choices. Worth a look.
21
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 41
Use it when a form needs one precise point: a store, branch or sensor location in an admin screen, a delivery or pickup pin at checkout, an …
- Very specific: line 49
Clear errors, generated test results: Every runtime problem has a stable code and a one-line fix (error codes).
- Very specific: line 90
Every commit fires input (final value), then change, with detail.source: user, api, geolocation or search.
- Very specific: line 164
Add search locate="button": field mode uses the same attributes as the inline map and adds nothing on its own.
- Very specific: line 167
Use the inline map when the map is the content, or when the form is used on desktops.
- Very specific: line 305
Read the current state from properties: el.value ("lat,lng" or ""), el.valueAsLatLng ({ lat, lng } or null), el.address and el.map (the …
- Very specific: line 328
If the page already loads the Maps JavaScript API, provider="google" uses it and no key attribute is needed.
- Very specific: line 359
Every option is also an element property and (for primitives) a kebab-case attribute: latInput = lat-input.
- Very specific: line 406
Put
requiredon the picker,: never on hidden bound inputs. - Very specific: line 407
jQuery
.serialize()skips custom elements: Use bound inputs (lat-input/lng-input) or new FormData(form). - Very specific: line 415
Every runtime problem is reported with a stable code and a one-line fix, for example [location-picker] … Fix: Add api-key="YOUR_KEY", or …
- Distinctive: line 433
Every v1 and v2 API, what changed and the one-line fixes: docs/migration.md.
- Very specific: line 443
Start it with ?lat=48.8584&lng=2.2945 (a starting point to confirm or adjust), zoom (1 to 19), q=Eiffel Tower (picks the first search …
- Distinctive: line 512
When the page already has inputs, let the picker fill them instead of adding its own entries.
- Distinctive: line 529
Put the bound inputs inside the element:
- Distinctive: line 532
After the element upgrades they are no longer rendered, but they are still submitted, and the map writes them.
- Very specific: line 636
If a *-name attribute equals the name of a bound input in the same form, the value would be submitted twice.
- Very specific: line 657
Every snippet loads https://cdn.jsdelivr.net/npm/location-picker@3.
- Very specific: line 725
Add a Custom HTML block and paste the snippet.
- Distinctive: line 779
Content Security Policy: if your platform lets you set one, see csp.md.
- Very specific: line 828
Import location-picker in a client component, and the JSX typings once.
- Very specific: line 881
Use onerror (lowercase) for the picker's errors.
- Very specific: line 940
Do not use @input there: it writes the live value back during a pan (see the rules at the top of this page).
- Very specific: line 946
Use ngDefaultControl: Angular's DefaultValueAccessor writes the value property and listens to input and blur, which is exactly what the …
- Very specific: line 1108
After mounting, the picker checks that the CSS applied, and reports ENGINE_CSS_MISSING (a warning) with a fix if not.
- Very specific: line 1281
When the provider name changes (for example from google to leaflet), the address is cleared silently, so Google text never stays next to an …
- Very specific: line 1338
If you load location-picker itself from jsDelivr (https://cdn.jsdelivr.net/npm/location-picker@3), that is already covered by the …
≈ “Common” is an estimate: at least 1 in 5 of the 3,225 llms.txt files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
# location-picker: full documentation
> Form-ready map location picker: pan a map under a fixed pin to pick one latitude/longitude. Web component + class, keyless OpenStreetMap by default, Google and MapLibre optional.
Generated by scripts/build-llms-full.mjs from the README and docs/ of https://github.com/cyphercodes/location-picker. Short version: https://cyphercodes.github.io/location-picker/llms.txt
---
Source: https://github.com/cyphercodes/location-picker/blob/master/README.md
# location-picker
**Let people pick an exact spot on a map, and get `lat,lng` in your form.**
Form-ready map location picker: pan a map under a fixed pin to pick one latitude/longitude. Web component + class, keyless OpenStreetMap by default, Google and MapLibre optional.

**[Live demo](https://cyphercodes.github.io/location-picker/)** · [No-code embed](https://cyphercodes.github.io/location-picker/embed/) · [Pick link](https://cyphercodes.github.io/location-picker/pick/) · [API reference](https://github.com/cyphercodes/location-picker/blob/master/docs/api.md) · [For AI agents](#for-ai-agents) · MIT
One script tag, no API key, no build step. Put the element inside your form:
```html
<script src="https://cdn.jsdelivr.net/npm/location-picker@3"></script>
<form method="post" action="/places">
<label for="where">Location</label>
<location-picker id="where" search locate="button" name="location" required></location-picker>
<button>Save</button>
</form>
```
People pan the map under the pin, search for a place, paste coordinates or tap the locate button (the browser asks for permission only then). On Save, the form posts `location=48.85837,2.294481`, latitude first, like any other field. In JavaScript, read the element's `value` or listen for `change`: `e.detail` has `value`, `lat`, `lng`, `source` and `address`.
- **With npm:** `npm i location-picker`, then `import 'location-picker'`. Recipes for React, Vue, Angular, Svelte and more: [Frameworks](#frameworks).
- **With a coding agent:** `npx skills add cyphercodes/location-picker` adds the location-picker Agent Skill (it also ships in the package, at `node_modules/location-picker/skills/location-picker/SKILL.md`). [llms.txt](https://cyphercodes.github.io/location-picker/llms.txt) is the short version of these docs, and [Common mistakes](#common-mistakes) lists the traps.
- **Without code:** the [embed generator](https://cyphercodes.github.io/location-picker/embed/) writes the snippet for WordPress, Webflow, Squarespace, Shopify or a plain HTML form ([per-platform guide](https://github.com/cyphercodes/location-picker/blob/master/docs/embed.md)).
- **To ask a person:** send them the [pick link](https://cyphercodes.github.io/location-picker/pick/). They confirm a point on a map with nothing to install, and you get it back as text they copy to you, an `https://` redirect after their click, or a `postMessage` to your origin ([how it works](#ask-a-person-for-a-location-pick-link)). Made for chat bots, AI agents and support tickets. Try [`/pick/?q=Eiffel Tower`](https://cyphercodes.github.io/location-picker/pick/?q=Eiffel%20Tower).
## Why location-picker
Use it when a form needs **one precise point**: a store, branch or sensor location in an admin screen, a delivery or pickup pin at checkout, an event venue, the place of a report, a home or work location in a profile.
- **A real form field.** `required` validates, form reset works and back/forward navigation restores it. Submit one `"lat,lng"` entry (`name`), separate entries (`lat-name`, `lng-name`), or fill inputs you already have (`lat-input`, `lng-input`). See [Forms](#forms).
- **No API key to start.** OpenStreetMap through Leaflet by default; Google Maps or MapLibre when you choose them. The default OpenStreetMap tiles and Photon address search are free public services with no SLA: for production traffic, use your own or a commercial tile server and geocoder ([usage policies](#providers-and-usage-policies)).
- **Pan, search, paste or locate.** Arrow keys and Enter work too, and the locate button asks for permission only when clicked. Typed or pasted coordinates jump straight there with no network call: `48.8584, 2.2945`, DMS, `geo:` URIs and Google, OpenStreetMap or Apple Maps links.
- **Phone-first when you want it.** [Field mode](#phone-first-field-mode) shows a compact control that opens the map full screen; the value changes only on Confirm.
- **Your stack.** Plain HTML forms and server templates (Laravel, Django, Rails), React, Vue, Angular, Svelte, Solid and Preact; jQuery, Livewire, Alpine and htmx through bound inputs. TypeScript types and JSX typings are included.
- **No runtime npm dependencies.** Leaflet, MapLibre and the Google Maps types are optional peers. When the page has no Leaflet, it is loaded from jsDelivr ([Install](#install)).
- **Clear errors, generated test results.** Every runtime problem has a stable `code` and a one-line fix ([error codes](https://github.com/cyphercodes/location-picker/blob/master/docs/errors.md)). Browser and device results come from the test matrix: [COMPATIBILITY.md](https://github.com/cyphercodes/location-picker/blob/master/COMPATIBILITY.md).
**When not to use it:** drawing shapes, routes or areas; several markers; a full mapping application (use Leaflet, MapLibre or the Google Maps JS API directly); text-only address autocomplete with no map; offline maps; React Native or other native mobile apps.
## Install
| How | Use |
| ----------------------- | ---------------------------------------------------------------------------------------- |
| Script tag (no bundler) | `<script src="https://cdn.jsdelivr.net/npm/location-picker@3"></script>` |
| npm | `npm i location-picker`, then `import 'location-picker'` |
| npm, class only | `import { LocationPicker } from 'location-picker/core'` (defines no element) |
| Upgrading 1.x code | `import locationPicker from 'location-picker/v1'` ([migration guide](https://github.com/cyphercodes/location-picker/blob/master/docs/migration.md)) |
The script tag defines `<location-picker>` and sets `window.LocationPicker` (the class, with every export as a static). `https://unpkg.com/location-picker@3` serves the same file. Keep the `@3`: the unpinned `/dist/location-picker.min.js` path is the frozen v1 file. In ES modules from a CDN, `https://cdn.jsdelivr.net/npm/location-picker@3/+esm` defines the element too.
Leaflet 1.9.4 is loaded on demand from jsDelivr with Subresource Integrity, only when `window.L` is absent. Bundler users can pass their own Leaflet instead (see [the JavaScript class](#javascript-class)).
To watch the value change while you try it, here is a complete page:
```html
<!doctype html>
<html lang="en">
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pick a location</title>
<script src="https://cdn.jsdelivr.net/npm/location-picker@3"></script>
<label for="where">Where is it?</label>
<location-picker id="where" search locate="button" style="height: 400px"></location-picker>
<p>Picked: <output id="out">nothing yet</output></p>
<script>
document.getElementById('where').addEventListener('change', (e) => {
document.getElementById('out').textContent = e.detail.value; // "48.85837,2.294481"; e.detail has lat, lng, source, address
});
</script>
</html>
```
## How the value works
- The picker starts **unset** (`value === ""`) unless you give it a `value`. While unset, the map shows `center` (or a world view) and the pin is dimmed.
- The user **commits** a point by panning the map, using the arrow keys, pressing Enter on the map, clicking the confirm button (shown while unset when `center` is set), picking a search result, or locating. `setLocation()` commits too.
- Zooming never changes the value: zoom is anchored at the pin.
- Every commit fires `input` (final value), then `change`, with `detail.source`: `user`, `api`, `geolocation` or `search`. Live `input` events fire while the user drags.
- Writing `el.value = '48.8584,2.2945'` is silent (no events), like a native `<input>`.
- The value string is always latitude first, dot decimal, no spaces, rounded to `precision` decimals (6 by default): `"48.85837,2.294481"`.
## Forms
The element is form-associated: it validates with `required`, resets with the form and restores on back/forward navigation. Choose what it submits:
| Attributes | Submitted entries |
| ------------------------------------------------ | ------------------------------------------------------------- |
| `name="loc"` | `loc=48.85837,2.294481` |
| `lat-name="lat" lng-name="lng"` | `lat=48.85837`, `lng=2.294481` |
| `+ address-name="addr"` | `addr=<address>` (`""` while unknown) |
| `lat-input="lat" lng-input="lng"` (bound inputs) | your existing inputs submit themselves; the picker fills them |
The recommended server-rendered pattern puts the inputs inside the element. Without JavaScript they are visible and typed by hand; with it they are hidden, filled by the map, and still submitted. Their server values seed the picker, and a form reset brings them back.
```blade
<form method="POST" action="{{ route('stores.update', $store) }}">
@csrf
@method('PUT')
<label for="store-location">Store location</label>
<location-picker id="store-location" lat-input="lat" lng-input="lng" address-name="address"
required search reverse-geocode style="height: 360px">
{{-- Without JS these are visible and typed by hand. With JS they are hidden, filled by the map, and still submitted.
Their values seed the picker. No `required` here: the picker validates. --}}
<input id="lat" name="lat" inputmode="decimal" value="{{ old('lat', $store->lat) }}" aria-label="Latitude">
<input id="lng" name="lng" inputmode="decimal" value="{{ old('lng', $store->lng) }}" aria-label="Longitude">
</location-picker>
@error('lat') <p class="error">{{ $message }}</p> @enderror
<button type="submit">Save</button>
</form>
<script src="https://cdn.jsdelivr.net/npm/location-picker@3"></script>
```
```php
// StoreController@update
$data = $request->validate([
'lat' => ['required', 'numeric', 'between:-90,90'],
'lng' => ['required', 'numeric', 'between:-180,180'],
'address' => ['nullable', 'string', 'max:255'],
]);
$store->update($data);
```
The same in a Django template:
```django
{% load l10n %}
<location-picker lat-input="lat" lng-input="lng" required search>
<input id="lat" name="lat" value="{{ form.lat.value|default_if_none:''|unlocalize }}" aria-label="Latitude">
<input id="lng" name="lng" value="{{ form.lng.value|default_if_none:''|unlocalize }}" aria-label="Longitude">
</location-picker>
```
jQuery, Livewire (`wire:model`) and Alpine (`x-model`) listen on the bound inputs, which get native `input` and `change` events. jQuery `.serialize()` ignores custom elements, so use bound inputs or `new FormData(form)`. Style errors with `location-picker:state(interacted):invalid`. More in [docs/forms.md](https://github.com/cyphercodes/location-picker/blob/master/docs/forms.md).
## Phone-first: field mode
On a phone, a map in the middle of a checkout form gets in the way of scrolling. `presentation="field"` turns the picker into a compact form control: the chosen address (or the coordinates) and a **Choose on map** button. The map opens in a full-screen sheet with search, the locate button, **Confirm** and **Cancel**; the value changes only on Confirm. The map engine loads when the sheet first opens.
```html
<form method="post" action="/deliveries">
<label for="drop">Delivery spot</label>
<location-picker id="drop" presentation="auto" name="location" search locate="button" reverse-geocode required></location-picker>
<button>Continue</button>
</form>
<script src="https://cdn.jsdelivr.net/npm/location-picker@3"></script>
```
- `auto` shows the field on touch screens up to 640 px wide (`(pointer: coarse) and (max-width: 640px)`) and the inline map elsewhere; `field` always shows the field; `inline` (the default) always shows the map.
- Names, `FormData`, `required` and events are the same in every mode. Pans, search picks and locates inside the sheet are a draft: Confirm commits them (`input` then `change`, with their `source`); Cancel, Escape and `el.close()` change nothing and fire no `input`/`change`.
- `el.showPicker()` opens the sheet, `el.close()` cancels it, and the `toggle` event (a `ToggleEvent` with `oldState`/`newState`) fires on open and close.
- In field mode, `ready` and `el.map` wait for the first open. Do not gate the form on `el.ready`.
- Add `search locate="button"`: field mode uses the same attributes as the inline map and adds nothing on its own.
- A height you set on the element applies to the compact field too. Leave it off (the inline map is 320px tall by default), or set it in a `@media` rule that excludes phones.
Use the inline map when the map is the content, or when the form is used on desktops. Use `auto` (or `field`) in checkout, delivery and sign-up forms that people fill in on phones.
## Frameworks
Bind `value` (a `"lat,lng"` string) and update your state from `change`; `input` is for showing live updates. Do not write the value back from an `input` listener: in 3.0 that stops a pan in progress (Vue's `v-model` is fine). Spell configuration as HTML attributes (`lat-name`, `api-key`), which is SSR-safe.
**Event names per framework.** The class callbacks `onChange`/`onInput`/`onAddressChange`/`onReady`/`onError` are options of the JavaScript class only.
| Framework | `change` | `input` | `addresschange` | `ready` | `error` |
|---|---|---|---|---|---|
| Plain JS | `addEventListener('change')` | `'input'` | `'addresschange'` | `'ready'` | `'error'` |
| React 19 | `onChange` | `onInput` | `onaddresschange` | `onready` | `onerror` (NOT `onError`: React's synthetic `error` never fires for this element; a type error in `jsx/react`) |
| Vue 3 | `@change` | `@input` | `@addresschange` | `@ready` | `@error` |
| Angular | `(change)` | `(input)` | `(addresschange)` | `(ready)` | `(error)` |
| Svelte 5 | `onchange` | `oninput` | `onaddresschange` | `onready` | `onerror` |
| Solid | `on:change` | `on:input` | `on:addresschange` | `on:ready` | `on:error` |
| Preact | `onchange` | `oninput` | `onaddresschange` | `onready` | `onerror` |
React's lowercase native handlers are attached on client render only. After `hydrateRoot`, use a ref (React hydration recipe below). Field mode's `toggle` event is `ontoggle` in React, Preact and Svelte, `@toggle` in Vue, `(toggle)` in Angular and `on:toggle` in Solid.
### React 19 / Next.js App Router
```tsx
'use client';
import 'location-picker'; // defines <location-picker>; SSR-safe no-op on the server
import type {} from 'location-picker/jsx/react'; // JSX typings
import { useState } from 'react';
export function StoreLocationField({ initial = '' }: { initial?: string }) {
const [value, setValue] = useState(initial); // "lat,lng" or ""
// Attributes, value and onChange/onInput survive SSR hydration; objects (messages…) and onaddresschange go in a ref (see the React hydration recipe).
return (
<>
<label htmlFor="loc">Location</label>
<location-picker id="loc" name="location" lat-name="lat" lng-name="lng" required search
value={value} onChange={(e) => setValue(e.currentTarget.value)} style={{ height: 360 }} />
<p>{value || 'Pan the map to choose'}</p>
</>
);
}
// <form action={saveStore}>: formData.get('lat'), formData.get('lng'), formData.get('location')
```
**React hydration recipe.** With `hydrateRoot` (Next.js App Router, Remix), React applies neither object props (`messages`, `mapOptions`, `engine`, `geocoder`, `geolocation`, an adapter-object `provider`) nor the lowercase native handlers to custom elements. Set them in a ref callback; refs are attached on hydration. Attributes, `value` and `onChange`/`onInput` keep working.
```tsx
'use client';
import 'location-picker';
import type {} from 'location-picker/jsx/react';
import type { AddressDetail } from 'location-picker';
import { useState } from 'react';
export function AddressField() {
const [address, setAddress] = useState<string | null>(null);
return (
<>
<location-picker
name="location"
reverse-geocode
ref={(el) => {
if (!el) return;
el.messages = { locate: 'Use my position' };
const onAddress = (e: CustomEvent<AddressDetail>) => setAddress(e.detail.address);
el.addEventListener('addresschange', onAddress);
return () => el.removeEventListener('addresschange', onAddress);
}}
/>
<p>{address ?? 'No address yet'}</p>
</>
);
}
```
### Vue 3
```ts
// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue({ template: { compilerOptions: { isCustomElement: (tag) => tag === 'location-picker' } } })],
});
```
```vue
<script setup lang="ts">
import 'location-picker';
import { ref } from 'vue';
const location = ref(''); // v-model uses `input` + el.value; v-model.lazy uses `change`
const address = ref<string | null>(null);
</script>
<template>
<label for="loc">Location</label>
<location-picker id="loc" v-model="location" name="location" search reverse-geocode
@addresschange="address = $event.detail.address" style="height: 360px" />
<p>{{ location || 'Pan the map to choose' }} <small v-if="address">{{ address }}</small></p>
</template>
```
### Angular 17+ (reactive forms, no custom ControlValueAccessor)
```ts
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import 'location-picker';
@Component({
selector: 'app-store-form',
standalone: true,
imports: [ReactiveFormsModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<form [formGroup]="form" (ngSubmit)="save()">
<label for="loc">Location</label>
<location-picker id="loc" formControlName="location" ngDefaultControl search locate="button" style="height:360px"></location-picker>
@if (form.controls.location.touched && form.controls.location.invalid) { <p>Choose a location.</p> }
<button [disabled]="form.invalid">Save</button>
</form>`,
})
export class StoreFormComponent {
form = new FormGroup({ location: new FormControl('', { nonNullable: true, validators: [Validators.required] }) });
save() { const [lat, lng] = this.form.getRawValue().location.split(',').map(Number); console.log({ lat, lng }); }
}
```
Svelte 5, Solid, Preact, Nuxt, Astro, Livewire, Alpine, htmx and component tests in jsdom or happy-dom: [docs/frameworks.md](https://github.com/cyphercodes/location-picker/blob/master/docs/frameworks.md).
## Reading the result
| Event | `detail` | Fires |
| --------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `change` | `{ value, lat, lng, source, zoom, address, accuracy?, place? }` | on every commit; de-duplicated; never for property writes, form reset or restore |
| `input` | same as `change` | live while the user drags (at most once per frame), and with the final value before every `change` |
| `addresschange` | `{ value, lat, lng, address, result }` | when the address for the current value resolves (`search` or `reverse-geocode`) |
| `ready` | `{ provider, map }` | when the map is mounted, and again after each remount |
| `error` | a `LocationPickerError` with `code`, `fix`, `level` | on asynchronous failures; logged with its fix unless you call `preventDefault()` |
| `toggle` | a native `ToggleEvent` (`oldState`, `newState`) | field mode: when the map sheet opens or closes |
Read the current state from properties: `el.value` (`"lat,lng"` or `""`), `el.valueAsLatLng` (`{ lat, lng }` or `null`), `el.address` and `el.map` (the engine map, after `ready`). `el.ready` is a promise for the mounted picker.
## Google Maps
Google is used only when you ask for it: `provider="google"`, or `api-key`, `map-id` or `marker="advanced"`. Search then uses Google's `PlaceAutocompleteElement` and reverse geocoding uses the Google Geocoder, so Google results never appear on an OpenStreetMap map and vice versa.
```html
<script src="https://cdn.jsdelivr.net/npm/location-picker@3"></script>
<!-- Restrict the key by HTTP referrer. Enable Maps JavaScript API + Places API (New); Geocoding API only for reverse-geocode. -->
<form method="post" action="/checkout/address">
<label for="drop">Drop-off point</label>
<location-picker id="drop" provider="google" api-key="YOUR_BROWSER_KEY" name="dropoff" address-name="dropoff_address"
search reverse-geocode locate="button" required center="40.7128,-74.0060" style="height: 420px"></location-picker>
<button>Continue</button>
</form>
<script>
const drop = document.getElementById('drop');
drop.addEventListener('change', (e) => console.log(e.detail.lat, e.detail.lng, e.detail.source));
drop.addEventListener('error', (e) => console.log(e.detail.code, e.detail.fix)); // e.g. GOOGLE_AUTH_FAILED; also logged
drop.addEventListener('ready', (e) => console.log('google.maps.Map:', e.detail.map)); // no unhandled rejection on failure
</script>
```
If the page already loads the Maps JavaScript API, `provider="google"` uses it and no key attribute is needed. Wheel and double-click zoom stay anchored at the pin; a pinch on Google Maps zooms around the fingers and commits the new centre.
## JavaScript class
`LocationPicker` renders into an element you own (light DOM), for pages that do not use the custom element or that need the engine map synchronously.
```html
<div id="map" style="height: 400px"></div>
<input type="hidden" name="lat" id="lat"><input type="hidden" name="lng" id="lng">
```
```ts
import * as L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { LocationPicker } from 'location-picker/core'; // class only, no custom element
import { leafletProvider } from 'location-picker/leaflet';
const picker = new LocationPicker<L.Map>('#map', {
provider: leafletProvider<L.Map>({ engine: L }),
value: '51.5074,-0.1278',
zoom: 13,
search: true,
locate: 'button',
latInput: '#lat',
lngInput: '#lng',
onChange: ({ lat, lng, source }) => console.log(source, lat, lng),
});
L.circle(picker.valueAsLatLng!, { radius: 200 }).addTo(picker.map!); // synchronous: adapter object + engine present (never waits for CSS)
// later (SPA route change): picker.destroy();
```
Every option is also an element property and (for primitives) a kebab-case attribute: `latInput` = `lat-input`. Unknown option keys log a warning that lists the valid ones.
## Attributes
| Attribute | Default | Notes |
| -------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `value` | `""` (unset) | Initial value; lenient (`"48.8584, 2.2945"`, DMS, `geo:`, map URLs) |
| `center`, `zoom` | world view `20,0`; zoom 15 with a value or center, else 2 | `center` is where the view starts while unset; it is never submitted |
| `name`, `lat-name`, `lng-name`, `address-name` | `""` | FormData entries |
| `lat-input`, `lng-input`, `value-input`, `address-input` | `""` | Existing inputs to fill (id or CSS selector) |
| `required`, `disabled`, `readonly` | off | `readonly`: zoom allowed, the value cannot change |
| `provider` | `leaflet` (`google` when `api-key` or `map-id` is set) | `leaflet`, `google` or `maplibre` |
| `api-key`, `map-id` | `""` | Google only |
| `tile-url`, `attribution` | OpenStreetMap tiles and credit | Leaflet raster tiles; use your own tiles in production |
| `map-style` | `https://tiles.openfreemap.org/styles/liberty` | MapLibre only |
| `engine-url`, `engine-css-url` | `""` (automatic) | Engine JS and CSS to load: a URL, or `"none"` to never load them (you provide the engine) |
| `search`, `placeholder` | off | Search box; typed or pasted coordinates jump directly, with no network call |
| `reverse-geocode` | off | Look up the address after each commit (fires `addresschange`) |
| `locate` | `off` | `button` (prompts only on click) or `auto` (also tries once on load while unset); `""` = `button` |
| `marker` | `pin` | `none` hides the pin; `advanced` = Google AdvancedMarkerElement (needs `map-id`) |
| `gesture-handling` | `auto` | `cooperative`, `greedy` or `none`; `auto` is cooperative (Ctrl/⌘ + wheel) when the page scrolls |
| `max-zoom`, `precision` | 19 for OSM tiles; 6 decimals | |
| `presentation` | `inline` | `field` (compact control, full-screen map sheet) or `auto` (field on phones); see field mode |
| `webmcp` | off | Register experimental WebMCP tools for browser agents |
Methods: `setLocation(lat, lng)` or `setLocation('lat,lng')` (commits with source `api`), `locate()` (geolocates, never hangs), `geocode(query)`, `lookupAddress()`, `showPicker()` and `close()` (field mode), plus the native `checkValidity()`, `reportValidity()` and `setCustomValidity()`. Object properties (`messages`, `geocoder`, `engine`, `mapOptions`, `geolocation`) are set from JavaScript. Full list: [docs/api.md](https://github.com/cyphercodes/location-picker/blob/master/docs/api.md).
## Styling
The element renders into an open shadow root. Style it with CSS custom properties (`--lp-pin-color`, `--lp-pin-size`, `--lp-accent`, `--lp-tile-filter`, …), parts (`location-picker::part(pin)`), slots (`slot="marker"` for your own pin) and states (`location-picker:state(unset)`). It is 320px tall by default; set your own `height`. Dark mode follows `color-scheme`, and the layout uses logical properties for right-to-left pages. Field mode adds the parts `field`, `field-value`, `field-button`, `sheet`, `sheet-header` and `cancel-button` (Confirm is `confirm-button`) and the state `:state(open)`; its texts are the `chooseOnMap`, `fieldPlaceholder`, `cancel` and `confirm` keys of `messages`.
## Providers and usage policies
- **OpenStreetMap tiles** (the default) come from `https://tile.openstreetmap.org` under the [OSM tile usage policy](https://operations.osmfoundation.org/policies/tiles/): the "© OpenStreetMap contributors" attribution stays visible, and the service is best-effort with no SLA. For production traffic, use your own or a commercial tile server with `tile-url` and `attribution`.
- **Search and reverse geocoding** on Leaflet and MapLibre use [Photon](https://photon.komoot.io/), which is free under fair use with no SLA. Requests are debounced (at least 300 ms on the public endpoint), cached and need 3 characters. For production, self-host Photon or use a commercial endpoint: `geocoder: photonGeocoder({ url })`, or your own geocoder.
- **Google Maps** content stays on Google maps. The [Google Maps Platform Terms](https://cloud.google.com/maps-platform/terms) do not allow Google results on a non-Google map, so each provider gets its paired geocoder and a mismatch is refused with `GEOCODER_MISMATCH`. Switching providers clears the address.
- **MapLibre** is bring-your-own (`maplibre-gl` and its CSS), with OpenFreeMap vector tiles by default.
Nominatim is never a default and is not used by any example here. Before writing your own Nominatim geocoder, read its usage policy at https://operations.osmfoundation.org/policies/nominatim/: it forbids client-side autocomplete and caps use at one request per second.
Details, self-hosting and custom geocoders: [docs/providers.md](https://github.com/cyphercodes/location-picker/blob/master/docs/providers.md). Content Security Policy recipes for each setup: [docs/csp.md](https://github.com/cyphercodes/location-picker/blob/master/docs/csp.md).
## Common mistakes
- **The unpinned v1 URL.** `https://unpkg.com/location-picker/dist/location-picker.min.js` (and the same path on jsDelivr) is the frozen v1 file forever: global `locationPicker`, Google Maps only, no `<location-picker>` element. For v3, load `https://cdn.jsdelivr.net/npm/location-picker@3` and keep the `@3`.
- **Latitude first, no arrays.** Values are `"lat,lng"` strings or `{ lat, lng }` objects. Arrays are rejected because GeoJSON uses `[lng, lat]`. A swapped pair with both numbers in range (`"2.2945,48.8584"`) cannot be detected, so check the order.
- **Choose Google explicitly.** Google Maps is used only with `provider="google"`, `api-key`, `map-id` or `marker="advanced"`. Otherwise the picker uses OpenStreetMap, even when `google.maps` is already loaded on the page.
- **Put `required` on the picker,** never on hidden bound inputs. A hidden, empty, `required` input blocks the submit as "not focusable".
- **jQuery `.serialize()` skips custom elements.** Use bound inputs (`lat-input`/`lng-input`) or `new FormData(form)`.
- **`import 'location-picker'` defines the element.** `location-picker/core` is the class only and defines nothing, so importing only `core` leaves `<location-picker>` blank.
- **Read results from `value` or `valueAsLatLng`,** not `getMarkerPosition()`. While the picker is unset, `getMarkerPosition()` returns the view centre (for example the 20,0 world view), not a chosen point.
- **The `lat`/`lng` attributes are the initial value only.** Once the control is dirty (a pan, or the `el.value = ''` that `v-model` and `formControlName` write on mount), changing them is ignored with a warning. Bind `value="lat,lng"` or call `setLocation()`.
- **React SSR (`hydrateRoot`: Next.js App Router, Remix):** React applies no object props (`messages`, `geocoder`, …) and no lowercase native handlers (`onaddresschange`, `onready`, `onerror`) during hydration. Set them in a ref callback (see the React hydration recipe).
- **MapLibre CSS is yours to load:** `import 'maplibre-gl/dist/maplibre-gl.css'`, or set `engine-css-url`. The library never fetches MapLibre or its CSS by default.
- **The stylesheet path is `location-picker/style.css`.** `location-picker/dist/location-picker.css` is not exported. You only need it with `injectStyles: false`.
Every runtime problem is reported with a stable code and a one-line fix, for example `[location-picker] … Fix: Add api-key="YOUR_KEY", or load the Google Maps JS API before the picker.` See [docs/errors.md](https://github.com/cyphercodes/location-picker/blob/master/docs/errors.md) and [docs/troubleshooting.md](https://github.com/cyphercodes/location-picker/blob/master/docs/troubleshooting.md).
## Upgrading from 1.x or 2.x
The old script URL keeps working: `dist/location-picker.min.js` is a frozen copy of the v1 API (global `locationPicker`, Google Maps) with the longitude-wrap fix. npm users of 1.x change one import; 2.x Google code keeps a synchronous map with an adapter object:
```ts
// 1.x npm code: one-line change, identical behaviour (frozen file)
import locationPicker from 'location-picker/v1';
// 2.x Google code: keep a synchronous google.maps.Map with an adapter object
import { LocationPicker } from 'location-picker/core';
import { googleProvider } from 'location-picker/google';
const lp = new LocationPicker<google.maps.Map>('map', { provider: googleProvider(), value: { lat: 45.5, lng: -73.56 },
onChange: (d) => console.log(d.lat, d.lng) });
google.maps.event.addListener(lp.map!, 'idle', () => console.log(lp.getMarkerPosition()));
```
Every v1 and v2 API, what changed and the one-line fixes: [docs/migration.md](https://github.com/cyphercodes/location-picker/blob/master/docs/migration.md).
## Compatibility
Browser, device and framework results are generated by the test matrix, never written by hand: see [COMPATIBILITY.md](https://github.com/cyphercodes/location-picker/blob/master/COMPATIBILITY.md) and the [compatibility page](https://cyphercodes.github.io/location-picker/compat/).
## Ask a person for a location: pick link
`https://cyphercodes.github.io/location-picker/pick/` is a hosted page that asks a person for an exact location with nothing to install: send the link from a chat bot, an AI agent, a support ticket or a no-code automation. The person moves the map (or searches, or taps "Use my location") and confirms.
- Start it with `?lat=48.8584&lng=2.2945` (a starting point to confirm or adjust), `zoom` (1 to 19), `q=Eiffel Tower` (picks the first search result) and `lang`.
- By default the page shows the result with copy buttons (decimal, DMS, `geo:` URI, Google Maps and OpenStreetMap links) and Web Share.
- `redirect=https://example.com/done` sends the person there after a click, with `lat`, `lng` and (when known) `address` appended. Only `https://` URLs are accepted, and the page shows the destination host first.
- `return=postmessage&origin=https://example.com` posts `{ type: 'location-picker:pick', lat, lng, address }` to the page that opened it (`window.open`) or embeds it (an iframe), only to that exact origin. `address` is `null` while unknown.
- The page has no server, no storage, no cookies and no analytics; the state lives only in the URL. It uses OpenStreetMap and Photon. For heavy use, host your own copy: `node scripts/build-site.mjs` builds the static site (MIT) into `_site/`, with the page at `pick/`.
Details and a `postMessage` listener: [docs/ai-agents.md](https://github.com/cyphercodes/location-picker/blob/master/docs/ai-agents.md#ask-a-person-for-a-location-the-pick-link).
## For AI agents
- [llms.txt](https://cyphercodes.github.io/location-picker/llms.txt) and [llms-full.txt](https://cyphercodes.github.io/location-picker/llms-full.txt)
- An Agent Skill, shipped in the npm package: [skills/location-picker/SKILL.md](https://github.com/cyphercodes/location-picker/blob/master/skills/location-picker/SKILL.md)
- `custom-elements.json` (the package's `customElements` field) for editor and agent tooling
- Typed errors with a `code` and a `fix`, and JSDoc with defaults and examples on every option
- Opt-in WebMCP tools so browser agents can read and set the location: [docs/ai-agents.md](https://github.com/cyphercodes/location-picker/blob/master/docs/ai-agents.md)
- The [pick link](#ask-a-person-for-a-location-pick-link), the zero-install way for an agent or a bot to ask a person for a location
Working on this repository: [AGENTS.md](https://github.com/cyphercodes/location-picker/blob/master/AGENTS.md).
## Documentation
[API reference](https://github.com/cyphercodes/location-picker/blob/master/docs/api.md) · [Forms](https://github.com/cyphercodes/location-picker/blob/master/docs/forms.md) · [No-code embed](https://github.com/cyphercodes/location-picker/blob/master/docs/embed.md) · [Frameworks](https://github.com/cyphercodes/location-picker/blob/master/docs/frameworks.md) · [Providers](https://github.com/cyphercodes/location-picker/blob/master/docs/providers.md) · [CSP](https://github.com/cyphercodes/location-picker/blob/master/docs/csp.md) · [Errors](https://github.com/cyphercodes/location-picker/blob/master/docs/errors.md) · [Troubleshooting](https://github.com/cyphercodes/location-picker/blob/master/docs/troubleshooting.md) · [Migration](https://github.com/cyphercodes/location-picker/blob/master/docs/migration.md) · [AI agents](https://github.com/cyphercodes/location-picker/blob/master/docs/ai-agents.md) · [Changelog](https://github.com/cyphercodes/location-picker/blob/master/CHANGELOG.md) · [Contributing](https://github.com/cyphercodes/location-picker/blob/master/CONTRIBUTING.md)
## License
[MIT](https://github.com/cyphercodes/location-picker/blob/master/LICENSE) © 2018-2026 Rayan Salhab. The MIT License applies to all prior versions of location-picker as well.
---
Source: https://github.com/cyphercodes/location-picker/blob/master/docs/forms.md
# Forms
`<location-picker>` is a form-associated custom element (built on `ElementInternals`). Inside a `<form>` it behaves like a native input: it submits a value, takes part in validation, resets with the form and restores its state on back/forward navigation.
## What gets submitted
Pick the names you want. The value is always latitude first, dot decimal, no spaces.
| Configuration | Entries when set | Entries when unset |
| ------------------------------------------- | ------------------------------------- | ---------------------------- |
| `name="loc"` | `loc=48.85837,2.294481` | `loc=` |
| `lat-name="lat" lng-name="lng"` | `lat=48.85837`, `lng=2.294481` | `lat=`, `lng=` |
| `name` + `lat-name` + `lng-name` | all three | all three empty |
| `+ address-name="addr"` | `addr=<address>` (`""` while unknown) | `addr=` |
| none of the above | none | none; validation still works |
| `disabled`, or inside `<fieldset disabled>` | none | none |
`address-name` needs `search` or `reverse-geocode`. The address may arrive a moment after the coordinates (reverse geocoding is debounced by 400 ms).
```html
<form method="post" action="/stores">
<label for="loc">Store location</label>
<location-picker
id="loc"
name="location"
lat-name="lat"
lng-name="lng"
required
search
style="height: 360px"
></location-picker>
<button>Save</button>
</form>
<script src="https://cdn.jsdelivr.net/npm/location-picker@3"></script>
```
## Bound inputs
When the page already has inputs, let the picker fill them instead of adding its own entries. Pass an id (tried first) or a CSS selector:
| Attribute (class option) | Receives |
| -------------------------------- | -------------------------------- |
| `lat-input` (`latInput`) | the latitude |
| `lng-input` (`lngInput`) | the longitude |
| `value-input` (`valueInput`) | `"lat,lng"` |
| `address-input` (`addressInput`) | the address (`""` while unknown) |
- The bound inputs submit themselves; the picker never adds entries for them.
- Their initial values seed the picker when it has no `value`.
- They mirror the picker's events: a native `input` event for each live change, `input` + `change` on each commit, and nothing for silent writes (property writes, reset, restore). Their events are dispatched before the picker's own.
- Listeners on the input itself keep working: jQuery `$('#lat').on('change', …)`, Livewire `wire:model`, Alpine `x-model`.
- Clearing the value with a property write, a reset to an empty value or a restore sets them to `""` silently. They are never cleared at startup.
## Progressive enhancement (Laravel, Django, Rails)
Put the bound inputs inside the element:
- Without JavaScript they are visible and typed by hand.
- After the element upgrades they are no longer rendered, but they are still submitted, and the map writes them.
- Their server-rendered values seed the picker, even when the script runs in `<head>` before they are parsed.
- Their native events do not bubble, so a `change` listener on the picker only ever receives the picker's own `CustomEvent`.
- A form reset brings back the server values in the inputs and in the picker.
```blade
<form method="POST" action="{{ route('stores.update', $store) }}">
@csrf
@method('PUT')
<label for="store-location">Store location</label>
<location-picker id="store-location" lat-input="lat" lng-input="lng" address-name="address"
required search reverse-geocode style="height: 360px">
{{-- Without JS these are visible and typed by hand. With JS they are hidden, filled by the map, and still submitted.
Their values seed the picker. No `required` here: the picker validates. --}}
<input id="lat" name="lat" inputmode="decimal" value="{{ old('lat', $store->lat) }}" aria-label="Latitude">
<input id="lng" name="lng" inputmode="decimal" value="{{ old('lng', $store->lng) }}" aria-label="Longitude">
</location-picker>
@error('lat') <p class="error">{{ $message }}</p> @enderror
<button type="submit">Save</button>
</form>
<script src="https://cdn.jsdelivr.net/npm/location-picker@3"></script>
```
```php
// StoreController@update
$data = $request->validate([
'lat' => ['required', 'numeric', 'between:-90,90'],
'lng' => ['required', 'numeric', 'between:-180,180'],
'address' => ['nullable', 'string', 'max:255'],
]);
$store->update($data);
```
```django
{% load l10n %}
<location-picker lat-input="lat" lng-input="lng" required search>
<input id="lat" name="lat" value="{{ form.lat.value|default_if_none:''|unlocalize }}" aria-label="Latitude">
<input id="lng" name="lng" value="{{ form.lng.value|default_if_none:''|unlocalize }}" aria-label="Longitude">
</location-picker>
```
`unlocalize` keeps Django from writing a decimal comma. Validate on the server too, for example with Django's `DecimalField(min_value=-90, max_value=90)` for latitude:
```python
from django import forms
class StoreForm(forms.Form):
lat = forms.DecimalField(min_value=-90, max_value=90, decimal_places=6)
lng = forms.DecimalField(min_value=-180, max_value=180, decimal_places=6)
```
Hidden inputs outside the element work the same way (the most common pattern in existing apps):
```html
<form method="post" action="/places">
<input type="hidden" name="lat" id="lat" value="48.8584" />
<input type="hidden" name="lng" id="lng" value="2.2945" />
<location-picker lat-input="lat" lng-input="lng" required style="height: 360px"></location-picker>
<button>Save</button>
</form>
<script src="https://cdn.jsdelivr.net/npm/location-picker@3"></script>
```
## Validation
- `required` reports `valueMissing` while the picker is unset, with the message "Please choose a location on the map." (`messages.valueMissing`). `reportValidity()` focuses the map.
- `setCustomValidity(message)` works as on native inputs.
- **Put `required` on the picker, never on hidden bound inputs.** A hidden, empty, `required` input blocks the submit as "not focusable", and the picker warns about it.
- `readonly` (zoom allowed, the value cannot change) and `disabled` bar the element from validation, as on native inputs.
- Style errors after the user interacted with `location-picker:state(interacted):invalid`. `:user-invalid` is not reliable on custom elements.
- `:required` and `:read-only` never work on form-associated custom elements. Use `location-picker[required]` and `location-picker[readonly]`.
- `center` is only where the view starts. It is never submitted; the user has to commit a point (pan, Enter, the confirm button, search, locate).
```html
<style>
location-picker:state(interacted):invalid {
outline: 2px solid crimson;
}
location-picker[required] {
border-inline-start: 4px solid #1d4ed8;
}
</style>
<form method="post" action="/places">
<location-picker name="location" required center="48.8584,2.2945"></location-picker>
<button>Save</button>
</form>
<script src="https://cdn.jsdelivr.net/npm/location-picker@3"></script>
```
## Reset and back/forward
- `form.reset()` returns the picker to its reset value: the `value` attribute, else the `lat` + `lng` attributes, else the bound inputs' server-rendered values, else unset. It then writes that value into the bound inputs. Nothing fires: no `input`, no `change`.
- Back/forward navigation (bfcache, history) restores the value, the zoom and the address, again silently.
- The dirty flag works like a native input: once the user (or a property write) changed the value, later changes to the `value`, `lat` or `lng` attributes only update the default. A `lat`/`lng` change ignored this way logs one warning. To control the value, bind the `value` property or call `setLocation()`.
## jQuery, AJAX, Livewire, Alpine, htmx
- **jQuery `.serialize()` ignores custom elements:** it only serializes `input`, `select` and `textarea`. Use bound inputs, or `new FormData(form)`.
- **Livewire and Alpine:** bind the inputs (`wire:model="lat"`, `x-model="lat"`) and point `lat-input`/`lng-input` at them.
- **Delegated listeners** (`$(form).on('change', 'input', …)`, htmx `hx-trigger="change"` on the form) do not see the native events of inputs nested in the picker, because those do not bubble. Listen to the picker's own `change` instead: `hx-trigger="change from:location-picker"`.
## Name collisions
If a `*-name` attribute equals the `name` of a bound input in the same form, the value would be submitted twice. The picker logs one warning ("use either lat-name or lat-input") and never changes your inputs.
## The JavaScript class in a form
`LocationPicker` is not a form control. In class mode, bind inputs with `latInput`/`lngInput`/`valueInput`/`addressInput`; their native events bubble normally.
```ts
import { LocationPicker } from 'location-picker';
const picker = new LocationPicker('#map', { latInput: '#lat', lngInput: '#lng', search: true });
picker.addEventListener('change', (e) => console.log(e.detail.value));
```
---
Source: https://github.com/cyphercodes/location-picker/blob/master/docs/embed.md
# No-code embed
You can add a map location field to a site builder or a hosted form without writing code. The [snippet generator](https://cyphercodes.github.io/location-picker/embed/) asks a few questions (the map, what the form should receive, the language, inline map or phone field) and gives you one block to paste, with a live preview. This page explains where to paste it on each platform, and where it cannot work.
Every snippet loads `https://cdn.jsdelivr.net/npm/location-picker@3`. Keep the `@3`: it pins the major version, so a future major release never changes your page. The unpinned `/dist/location-picker.min.js` URL is the old v1 file.
## What the form receives
The picker joins the form it sits in, like any input. Choose one of these in the generator:
| Choice | Attributes | The form receives |
| ------------------------------- | ----------------------------------------------- | ------------------------------------------------------ |
| One field | `name="location"` | `location=48.85837,2.294481` (latitude first) |
| Separate latitude and longitude | `lat-name="lat" lng-name="lng"` | `lat=48.85837` and `lng=2.294481` |
| Hidden inputs | `lat-input`, `lng-input` pointing at `<input>`s | whatever those inputs are named; the picker fills them |
| An address too | `address-name="address"` (or `address-input`) | the street address found for the point |
Choose **hidden inputs** when the platform only collects real `<input>` elements (Webflow, Netlify Forms, Shopify, most WordPress form plugins). The picker writes the coordinates into them and fires their native `input` and `change` events.
For a form that people fill in on phones (delivery, pickup, sign-up), choose **Automatic** under Presentation (`presentation="auto"`): on a phone the picker is a compact field with a **Choose on map** button that opens the map full screen, and on a desktop it is the inline map. See [field mode](https://github.com/cyphercodes/location-picker/blob/master/README.md#phone-first-field-mode).
A minimal snippet (OpenStreetMap, one field, search and a required check):
```html
<script src="https://cdn.jsdelivr.net/npm/location-picker@3"></script>
<label for="location-field">Location</label>
<location-picker
id="location-field"
name="location"
search
required
style="height: 360px"
></location-picker>
```
## Plain HTML forms
Tick **Include a complete form** in the generator and set the form's action. Form services such as [Formspree](https://formspree.io/) and [Basin](https://usebasin.com/) accept any field names, so one `location` field or `lat` and `lng` both work.
```html
<script src="https://cdn.jsdelivr.net/npm/location-picker@3"></script>
<form method="post" action="https://formspree.io/f/YOUR_FORM_ID">
<label for="location-field">Meeting point</label>
<location-picker id="location-field" lat-name="lat" lng-name="lng" search locate="button" required style="height: 360px"></location-picker>
<button type="submit">Send</button>
</form>
```
[Netlify Forms](https://docs.netlify.com/forms/setup/) registers the fields it finds in your HTML when the site is deployed, so it never sees fields that a script adds later. Choose **hidden inputs** so the fields are in the HTML, and add `data-netlify="true"` and a `name` to the form:
```html
<form name="delivery" method="post" action="/thanks/" data-netlify="true">
<label for="location-field">Delivery spot</label>
<location-picker
id="location-field"
presentation="auto"
lat-input="location-field-lat"
lng-input="location-field-lng"
search
locate="button"
required
>
<input type="hidden" id="location-field-lat" name="lat" />
<input type="hidden" id="location-field-lng" name="lng" />
</location-picker>
<button type="submit">Send</button>
</form>
<script src="https://cdn.jsdelivr.net/npm/location-picker@3"></script>
```
## WordPress
Add a **Custom HTML** block and paste the snippet. WordPress keeps `<script>` tags in that block only for users with the `unfiltered_html` capability (administrators on a self-hosted site); other roles and some hosted plans strip them.
Form plugins submit only the fields they define, so a picker next to a plugin form is not submitted with it. Instead:
1. Add hidden fields for the latitude and longitude in the plugin. In Contact Form 7, put `[hidden lat]` and `[hidden lng]` in the form template; they render `<input type="hidden" name="lat">` and `name="lng"`. Other plugins name their inputs differently: inspect the page to find the `name` or `id` of each hidden input.
2. In the generator choose **Hidden inputs** and tick **My form already has these fields**. The snippet then points `lat-input` and `lng-input` at those inputs with CSS selectors:
```html
<script src="https://cdn.jsdelivr.net/npm/location-picker@3"></script>
<label for="location-field">Location</label>
<location-picker
id="location-field"
lat-input="[name='lat']"
lng-input="[name='lng']"
search
required
style="height: 360px"
></location-picker>
```
Without a form plugin, use a complete form posting to a form service (see [plain HTML forms](#plain-html-forms)).
## Webflow
Put a **Code Embed** element inside your **Form Block** and paste the snippet. Webflow's form handler only collects real inputs, so choose **Hidden inputs** in the generator: the snippet then carries its own hidden `<input>`s, named as you chose, inside the embed. Custom code needs a Webflow plan that allows it.
## Squarespace
The built-in Form Block cannot take extra fields. Add a **Code Block** instead and paste a snippet with **Include a complete form** ticked, posting to Formspree, Basin or your own endpoint (see [plain HTML forms](#plain-html-forms)). JavaScript in Code Blocks needs a Squarespace plan that allows it.
## Shopify
To save a delivery or pickup spot with an order line, add a **Custom Liquid** block to the product page and use hidden inputs named as line item properties, such as `properties[Delivery location]`. The inputs must sit inside the product form, or carry its `form="…"` id. That id depends on the theme; in Dawn-based themes it is `product-form-{{ section.id }}`.
```liquid
<script src="https://cdn.jsdelivr.net/npm/location-picker@3"></script>
<label for="delivery-spot">Delivery spot</label>
<location-picker id="delivery-spot" presentation="auto" value-input="delivery-spot-value" search locate="button">
<input type="hidden" id="delivery-spot-value" name="properties[Delivery location]" form="product-form-{{ section.id }}">
</location-picker>
```
The order then shows `Delivery location: 48.85837,2.294481`. Shopify's checkout does not run custom scripts, so the picker cannot be added to the checkout pages themselves.
## Where it cannot work
- **Google Forms** and **Microsoft Forms** have no custom fields and run no scripts. Ask for coordinates as text instead, or send people the [pick link](https://github.com/cyphercodes/location-picker/blob/master/docs/ai-agents.md#ask-a-person-for-a-location-the-pick-link) and have them paste the result.
- **Wix** runs HTML embeds inside an iframe, so the picker cannot join a Wix form. A complete form inside the same embed, posting to Formspree or Basin, does work. The "Use my location" button may be blocked inside that iframe.
- **Any iframe-based embed** has the same limit: an element inside an iframe cannot add fields to a form outside it.
## Before you go live
- **Maps and search have usage policies.** The default OpenStreetMap tiles and the Photon search are free, best-effort services for light use. A busy site should use its own or a commercial tile server and geocoder: see [providers](https://github.com/cyphercodes/location-picker/blob/master/docs/providers.md).
- **Google Maps** needs a browser key restricted by HTTP referrer, with the Maps JavaScript API and Places API (New) enabled, and is billed by Google. Paste it into the generator's key field; the generator itself stores nothing.
- **Content Security Policy:** if your platform lets you set one, see [csp.md](https://github.com/cyphercodes/location-picker/blob/master/docs/csp.md).
- **Validate on the server too:** latitude in [-90, 90], longitude in [-180, 180].
---
Source: https://github.com/cyphercodes/location-picker/blob/master/docs/frameworks.md
# Frameworks
`<location-picker>` is a standard custom element, so every framework can render it. The rules are the same everywhere:
- **Bind `value`**, a `"lat,lng"` string (`""` while unset). Writing it is silent (no events), so bindings never loop.
- **Update your state from `change`** (committed values). Use **`input`** only to display live updates. Every commit fires `input` (final value) then `change`.
- **Do not write the live value back from an `input` listener.** In 3.0, setting `value` while the user is moving the map (a drag or an arrow-key pan) stops the move without a commit, so `value` bound together with `onInput`/`oninput`/`on:input`/`@input` loses pans. Vue's `v-model` is not affected: Vue skips writes of an unchanged value.
- **Spell configuration as HTML attributes** (`lat-name`, `api-key`, `reverse-geocode`). That is SSR-correct; the hyphen-less spellings (`latname`) are accepted too.
- **Never bind `lat`/`lng`** in a template: they only set the initial value.
- Objects (`messages`, `mapOptions`, `engine`, `geocoder`, `geolocation`) are properties, set from JavaScript.
- `import 'location-picker'` defines the element and is safe on the server (it does nothing there).
## Value binding
| Framework | Value binding | Events | Notes |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| Plain `<form>` | FormData via ElementInternals | `change`, `input` | reset and bfcache restore handled |
| React 19 | `value={str}` (property) | `onChange`/`onInput` (synthetic); lowercase `onaddresschange`, `onready`, `onerror` (native; client render only) | `import type {} from 'location-picker/jsx/react'`; kebab-case config; after `hydrateRoot`, objects and native listeners go in a ref |
| Vue 3.5 | `v-model` (`input` + `el.value`); `v-model.lazy` (`change`) | `@change`, `@addresschange` | `compilerOptions.isCustomElement` |
| Angular 17+ | `formControlName`/`ngModel` + **`ngDefaultControl`** (DefaultValueAccessor: `value` property, `input`, `blur`) | `(change)`, `(addresschange)` | `CUSTOM_ELEMENTS_SCHEMA`; no custom ControlValueAccessor needed |
| Svelte 5 | `value={v}` | `onchange`, `onaddresschange` | `jsx/svelte` types |
| Solid | `prop:value` | `on:change` | `jsx/solid` types |
| jQuery / Livewire / Alpine / htmx | bound inputs (native events on the input itself) or `new FormData(form)` | `change` on the picker for delegated or form-level listeners (htmx `hx-trigger="change from:location-picker"`) | `.serialize()` ignores custom elements |
**Event names per framework.** The class callbacks `onChange`/`onInput`/`onAddressChange`/`onReady`/`onError` are options of the JavaScript class only.
| Framework | `change` | `input` | `addresschange` | `ready` | `error` |
|---|---|---|---|---|---|
| Plain JS | `addEventListener('change')` | `'input'` | `'addresschange'` | `'ready'` | `'error'` |
| React 19 | `onChange` | `onInput` | `onaddresschange` | `onready` | `onerror` (NOT `onError`: React's synthetic `error` never fires for this element; a type error in `jsx/react`) |
| Vue 3 | `@change` | `@input` | `@addresschange` | `@ready` | `@error` |
| Angular | `(change)` | `(input)` | `(addresschange)` | `(ready)` | `(error)` |
| Svelte 5 | `onchange` | `oninput` | `onaddresschange` | `onready` | `onerror` |
| Solid | `on:change` | `on:input` | `on:addresschange` | `on:ready` | `on:error` |
| Preact | `onchange` | `oninput` | `onaddresschange` | `onready` | `onerror` |
React's lowercase native handlers are attached on client render only. After `hydrateRoot`, use a ref (React hydration recipe below).
Field mode's `toggle` event (the map sheet opened or closed): `ontoggle` in React, Preact and Svelte, `@toggle` in Vue, `(toggle)` in Angular, `on:toggle` in Solid.
## React 19 and Next.js
Import `location-picker` in a client component, and the JSX typings once. React 19 sets `value`, `search`, `locate` and other props as properties when they exist on the element, and fires `onChange`/`onInput` for the element's events. React 18 sets attributes instead, so `value` binding needs React 19.
```tsx
'use client';
import 'location-picker'; // defines <location-picker>; SSR-safe no-op on the server
import type {} from 'location-picker/jsx/react'; // JSX typings
import { useState } from 'react';
export function StoreLocationField({ initial = '' }: { initial?: string }) {
const [value, setValue] = useState(initial); // "lat,lng" or ""
// Attributes, value and onChange/onInput survive SSR hydration; objects (messages…) and onaddresschange go in a ref (see the React hydration recipe).
return (
<>
<label htmlFor="loc">Location</label>
<location-picker id="loc" name="location" lat-name="lat" lng-name="lng" required search
value={value} onChange={(e) => setValue(e.currentTarget.value)} style={{ height: 360 }} />
<p>{value || 'Pan the map to choose'}</p>
</>
);
}
// <form action={saveStore}>: formData.get('lat'), formData.get('lng'), formData.get('location')
```
**React hydration recipe.** With `hydrateRoot` (Next.js App Router, Remix), React applies neither object props (`messages`, `mapOptions`, `engine`, `geocoder`, `geolocation`, an adapter-object `provider`) nor the lowercase native handlers to custom elements. Set them in a ref callback; refs are attached on hydration. Attributes, `value` and `onChange`/`onInput` keep working.
```tsx
'use client';
import 'location-picker';
import type {} from 'location-picker/jsx/react';
import type { AddressDetail } from 'location-picker';
import { useState } from 'react';
export function AddressField() {
const [address, setAddress] = useState<string | null>(null);
return (
<>
<location-picker
name="location"
reverse-geocode
ref={(el) => {
if (!el) return;
el.messages = { locate: 'Use my position' };
const onAddress = (e: CustomEvent<AddressDetail>) => setAddress(e.detail.address);
el.addEventListener('addresschange', onAddress);
return () => el.removeEventListener('addresschange', onAddress);
}}
/>
<p>{address ?? 'No address yet'}</p>
</>
);
}
```
- Use `onerror` (lowercase) for the picker's errors. `onError`, `onAddressChange` and `onReady` are type errors in `location-picker/jsx/react`: those names never fire for this element.
- In a Server Action, read `formData.get('location')` (or `lat`/`lng` with `lat-name`/`lng-name`).
- `<location-picker form="f1">` works in React 19: the `form` property writes the attribute.
## Vue 3 and Nuxt
Tell the compiler that `location-picker` is a custom element, then use `v-model` (live, `input`) or `v-model.lazy` (`change`).
```ts
// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue({ template: { compilerOptions: { isCustomElement: (tag) => tag === 'location-picker' } } })],
});
```
```vue
<script setup lang="ts">
import 'location-picker';
import { ref } from 'vue';
const location = ref(''); // v-model uses `input` + el.value; v-model.lazy uses `change`
const address = ref<string | null>(null);
</script>
<template>
<label for="loc">Location</label>
<location-picker id="loc" v-model="location" name="location" search reverse-geocode
@addresschange="address = $event.detail.address" style="height: 360px" />
<p>{{ location || 'Pan the map to choose' }} <small v-if="address">{{ address }}</small></p>
</template>
```
In Nuxt, set the same compiler option in `nuxt.config.ts`. The import is safe during server rendering.
```ts
// nuxt.config.ts
export default defineNuxtConfig({
vue: { compilerOptions: { isCustomElement: (tag) => tag === 'location-picker' } },
});
```
Vue's server-side compiler refuses `v-model` on custom elements (`v-model can only be used on <input>, <textarea> and <select>`), so server-rendered Nuxt templates bind `:value` and `@change` instead:
```vue
<script setup lang="ts">
import 'location-picker';
import { ref } from 'vue';
const location = ref(''); // "lat,lng" or ""
</script>
<template>
<form method="post" action="/api/places">
<label for="loc">Location</label>
<location-picker id="loc" :value="location" @change="location = $event.detail.value"
name="location" search style="height: 360px" />
<button>Save</button>
</form>
</template>
```
Do not use `@input` there: it writes the live value back during a pan (see the rules at the top of this page).
Types for templates: `import type {} from 'location-picker/jsx/vue'`. The element keeps its state across `<KeepAlive>`.
## Angular 17+
Use `ngDefaultControl`: Angular's DefaultValueAccessor writes the `value` property and listens to `input` and `blur`, which is exactly what the element provides. No custom ControlValueAccessor is needed. Add `CUSTOM_ELEMENTS_SCHEMA`.
```ts
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import 'location-picker';
@Component({
selector: 'app-store-form',
standalone: true,
imports: [ReactiveFormsModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<form [formGroup]="form" (ngSubmit)="save()">
<label for="loc">Location</label>
<location-picker id="loc" formControlName="location" ngDefaultControl search locate="button" style="height:360px"></location-picker>
@if (form.controls.location.touched && form.controls.location.invalid) { <p>Choose a location.</p> }
<button [disabled]="form.invalid">Save</button>
</form>`,
})
export class StoreFormComponent {
form = new FormGroup({ location: new FormControl('', { nonNullable: true, validators: [Validators.required] }) });
save() { const [lat, lng] = this.form.getRawValue().location.split(',').map(Number); console.log({ lat, lng }); }
}
```
- `[(ngModel)]` works the same way with `ngDefaultControl`.
- Listen to `(addresschange)="onAddress($event)"` for the address.
- With bound inputs nested inside the element, the control still holds `"lat,lng"`: the inputs' own events do not bubble to the host.
## Svelte 5
```svelte
<script lang="ts">
import 'location-picker';
import type {} from 'location-picker/jsx/svelte';
let location = $state('');
</script>
<label for="loc">Location</label>
<location-picker id="loc" name="location" search value={location}
onchange={(e) => (location = e.detail.value)} style="height: 360px"></location-picker>
<p>{location || 'Pan the map to choose'}</p>
```
## Solid
```tsx
import 'location-picker';
import type {} from 'location-picker/jsx/solid';
import { createSignal } from 'solid-js';
export function LocationField() {
const [location, setLocation] = createSignal('');
return (
<location-picker name="location" search prop:value={location()}
on:change={(e) => setLocation(e.detail.value)} style={{ height: '360px' }} />
);
}
```
## Preact
```tsx
/** @jsxImportSource preact */
import 'location-picker';
import type {} from 'location-picker/jsx/preact';
import { useState } from 'preact/hooks';
export function LocationField() {
const [location, setLocation] = useState('');
return (
<location-picker name="location" search value={location}
onchange={(e) => setLocation(e.detail.value)} style={{ height: '360px' }} />
);
}
```
## Astro and other server-rendered pages
Render the tag in the page and load the script on the client. In Astro, a `<script>` that imports the package is bundled for the browser:
```astro
<form method="post">
<location-picker name="location" required style="height: 360px"></location-picker>
<button>Save</button>
</form>
<script>
import 'location-picker';
</script>
```
Templates without a bundler (Laravel Blade, Django, Rails ERB, Express views) use the script tag and bound inputs: see [forms.md](https://github.com/cyphercodes/location-picker/blob/master/docs/forms.md).
## jQuery, Livewire, Alpine and htmx
These libraries listen on inputs, so give the picker bound inputs and let them receive native `input` and `change` events:
```html
<form method="post" action="/places">
<input type="hidden" name="lat" id="lat" /><input type="hidden" name="lng" id="lng" />
<location-picker lat-input="lat" lng-input="lng" required style="height: 360px"></location-picker>
<button>Save</button>
</form>
<script src="https://cdn.jsdelivr.net/npm/location-picker@3"></script>
<script>
document
.getElementById('lat')
.addEventListener('change', (e) => console.log('lat', e.target.value));
</script>
```
- jQuery: `$('#lat').on('change', …)` fires. `$(form).serialize()` skips the picker itself, so submit bound inputs or `new FormData(form)`.
- Livewire: `<input type="hidden" id="lat" wire:model="lat">`. Alpine: `x-model="lat"` on the input.
- htmx: `hx-trigger="change from:location-picker"`.
## TypeScript
- `document.querySelector('location-picker')` is typed as `LocationPickerElement` (the package augments `HTMLElementTagNameMap`).
- JSX and template typings are opt-in: `import type {} from 'location-picker/jsx/react'` (or `preact`, `solid`, `vue`, `svelte`). They type the attributes, the `locate` modes and the event handlers.
- `instanceof` can differ when two copies of the library are on one page (for example the ESM and the script build). Check with `customElements.get('location-picker')` or duck typing instead.
## Component tests in jsdom or happy-dom
jsdom and happy-dom lack parts of the platform the element needs (form internals, `ResizeObserver`, constructable stylesheets). There the element runs **inert**: it never throws, it logs one console message naming the missing API, and `value`, `valueAsLatLng` and `setLocation()` still round-trip, so components that render a picker can be unit tested. Test maps and form submission in a real browser.
---
Source: https://github.com/cyphercodes/location-picker/blob/master/docs/providers.md
# Providers, geocoders and usage policies
A provider is the map engine: `leaflet` (the default, OpenStreetMap, no key), `google` or `maplibre`. Each provider has a paired geocoder for the search box and for reverse geocoding.
| Provider | Engine | Loaded how | Search and reverse geocoding |
| ---------- | ----------------------------------- | -------------------------------------------------------- | ------------------------------------------- |
| `leaflet` | Leaflet 1.9.4 + OpenStreetMap tiles | `window.L`, or auto-loaded from jsDelivr with SRI | Photon |
| `google` | Google Maps JavaScript API | already on the page, or Google's bootstrap with your key | `PlaceAutocompleteElement`, Google Geocoder |
| `maplibre` | `maplibre-gl` + OpenFreeMap style | bring your own (never auto-loaded) | Photon |
## How the provider is chosen
1. An explicit `provider` wins: a name (`provider="google"`) or an adapter object (`provider: leafletProvider({ … })`). The aliases `osm` and `openstreetmap` (for `leaflet`) and `googlemaps` and `google-maps` (for `google`) work with a warning. Any other name is an `INVALID_OPTION` error: no map is created and `ready` rejects.
2. Otherwise a Google-only signal selects `google`: `api-key`, `map-id`, `marker="advanced"`, the deprecated `useAdvancedMarker`, or a 3rd constructor argument.
3. Otherwise `leaflet`. A `google.maps` global already on the page is never used by accident; it only triggers a one-time console hint.
MapLibre is never inferred: ask for it with `provider="maplibre"`. `lat`/`lng` are not a Google signal.
String providers are loaded lazily as separate chunks (the script build bundles all three). Adapter objects are imported statically and let `picker.map` exist synchronously. Options passed to the adapter factory win over the picker's own options.
## Leaflet and OpenStreetMap (default)
**Engine.** The adapter uses, in order: the `engine` option (`import * as L from 'leaflet'`), then `window.L`, then auto-loads `https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.js` with Subresource Integrity (`crossorigin="anonymous"` and your CSP nonce), once per page. `engine-url` points the auto-load somewhere else (no integrity check for custom URLs); `engine-url="none"` never loads anything and reports `ENGINE_MISSING` when `L` is absent. Leaflet 2 works through `engine`; the auto-load stays on 1.9.4.
RequireJS or AMD pages must load Leaflet before RequireJS, or pass `engine`, because Leaflet's UMD build calls an anonymous `define()`.
**Leaflet's CSS** is resolved without a forced download when your page already has it:
1. `engine-css-url` set to a URL: that stylesheet is linked.
2. The adapter auto-loaded Leaflet: the pinned `leaflet.css` is linked with integrity.
3. Readable `leaflet.css` rules on the page (for example `import 'leaflet/dist/leaflet.css'` in a bundle) are used; for the element they are copied into its shadow root.
4. Otherwise the pinned `leaflet.css` is linked from jsDelivr (skipped with `engine-css-url="none"`).
5. After mounting, the picker checks that the CSS applied, and reports `ENGINE_CSS_MISSING` (a warning) with a fix if not.
The map never waits for the CSS, so a bundled `L` gives you `picker.map` synchronously.
**Tiles.** The default is `https://tile.openstreetmap.org/{z}/{x}/{y}.png`, maximum zoom 19, with the "© OpenStreetMap contributors" credit always visible (the attribution is forced while the default tiles are used). OpenStreetMap's tiles are a shared community service with a [tile usage policy](https://operations.osmfoundation.org/policies/tiles/): keep the attribution visible, do not bulk-download, and expect best-effort availability with no SLA. For production traffic, use your own or a commercial tile server:
```ts
import { LocationPicker } from 'location-picker';
const picker = new LocationPicker('#map', {
tileUrl: 'https://tiles.example.com/{z}/{x}/{y}.png',
attribution: '© Example Maps © OpenStreetMap contributors',
maxZoom: 20,
});
```
On the element that is `tile-url="…" attribution="…" max-zoom="20"`. Changing `tile-url` swaps the layer without rebuilding the map. A custom `tile-url` without `attribution` logs a warning.
**Self-hosted Leaflet** (no CDN, for strict CSP):
```html
<location-picker name="location" engine-url="/vendor/leaflet/leaflet.js"
engine-css-url="/vendor/leaflet/leaflet.css" style="height: 360px"></location-picker>
<script src="/vendor/location-picker/location-picker.global.js"></script>
```
**Gestures.** Zoom is anchored at the pin (wheel, double-click and pinch), so zooming never changes the value. With `gesture-handling="auto"` (the default), a page that scrolls gets cooperative gestures: a plain wheel scrolls the page and shows "Use Ctrl + scroll to zoom the map" (⌘ on Apple platforms); one finger still pans the map. `greedy` always zooms, `none` disables gestures (buttons and keys only).
## Google Maps
Google is used only when you ask: `provider="google"`, or a key, a map ID or `marker="advanced"`.
**Loading.** The adapter uses, in order: the `engine` option (a `google.maps` namespace); a `google.maps.Map` already on the page (a classic `<script src="https://maps.googleapis.com/…">` page, mounted immediately); `google.maps.importLibrary`; and finally, when `api-key` is set, Google's official inline bootstrap, injected once per page with your key, `v=weekly`, the page language and your CSP nonce. With none of these you get `API_KEY_MISSING`.
**Your key.** Create a browser key, restrict it by HTTP referrer, and enable the Maps JavaScript API, Places API (New) for `search`, and the Geocoding API only for `reverse-geocode` or `lookupAddress()`. A rejected key (referrer restrictions, billing, a disabled API) is reported as `GOOGLE_AUTH_FAILED` on every Google picker on the page; an existing `window.gm_authFailure` handler is still called.
```ts
import { LocationPicker } from 'location-picker/core';
import { googleProvider } from 'location-picker/google';
const picker = new LocationPicker<google.maps.Map>('#map', {
provider: googleProvider({
apiKey: 'YOUR_BROWSER_KEY',
region: 'LB',
mapOptions: { mapTypeId: 'hybrid' },
}),
search: true,
locate: 'button',
});
const map = (await picker.ready).map;
```
To control loading yourself, `loadGoogleMaps({ apiKey })` injects the same bootstrap and resolves with the `google.maps` namespace.
**The pin** is a CSS overlay, so no Marker and no map ID are needed. `marker="advanced"` draws an `AdvancedMarkerElement` instead; it needs `map-id`, and without one the CSS pin stays and `MAP_ID_REQUIRED` is reported.
**Zoom.** Wheel, double-click and the zoom control are re-anchored at the pin, so they never change the value. A pinch on Google Maps zooms around the fingers, so the centre moves and the new centre is committed (source `user`).
**Search** uses Google's `PlaceAutocompleteElement` (never the legacy `places.Autocomplete`). A picked place moves the map and commits with its formatted address. Pasting coordinates (`48.85, 2.29`, a Google Maps URL) into the box jumps there without a Places request.
**Terms.** The [Google Maps Platform Terms of Service](https://cloud.google.com/maps-platform/terms) do not allow Google Maps content, such as Places results or Geocoding addresses, on or next to a non-Google map. The picker therefore pairs geocoders with providers and refuses a mismatch (`GEOCODER_MISMATCH`), and when an element switches between Google and another provider it clears the address. If you write a custom geocoder for a Google map, its results must come from Google.
**Colours.** Google's `colorScheme` follows the element's computed `color-scheme` at mount.
## MapLibre
MapLibre GL is bring-your-own: the library never downloads `maplibre-gl` or its CSS by default. The default style is OpenFreeMap's keyless `https://tiles.openfreemap.org/styles/liberty` ([OpenFreeMap](https://openfreemap.org/)).
```ts
import * as maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { LocationPicker } from 'location-picker/core';
import { maplibreProvider } from 'location-picker/maplibre';
const picker = new LocationPicker('#map', { provider: maplibreProvider({ engine: maplibregl }), search: true });
```
Without a bundler, load the engine yourself and give it to the element:
```html
<location-picker provider="maplibre" name="location" style="height: 360px"
engine-css-url="https://cdn.jsdelivr.net/npm/maplibre-gl@6/dist/maplibre-gl.css"></location-picker>
<script src="https://cdn.jsdelivr.net/npm/location-picker@3"></script>
<script type="module">
import * as maplibregl from 'https://cdn.jsdelivr.net/npm/maplibre-gl@6/+esm';
document.querySelector('location-picker').engine = maplibregl;
</script>
```
- **CSS:** readable MapLibre CSS on the page is used (and copied into the element's shadow root). Otherwise nothing is fetched and `ENGINE_CSS_MISSING` (a warning) tells you to `import 'maplibre-gl/dist/maplibre-gl.css'` or set `engine-css-url`.
- **Engine URL:** `engine-url` set to an ES module URL is loaded with `import()`. That is opt-in and cannot use Subresource Integrity.
- **Style:** `map-style` takes a style URL; the `mapStyle` property also takes a style object. Setting only `tile-url` wraps an XYZ raster template into a style.
- Rotation and pitch are off and zoom is anchored at the pin. `cooperative` maps to MapLibre's `cooperativeGestures`.
- MapLibre's worker needs `worker-src blob:` under a Content Security Policy ([csp.md](https://github.com/cyphercodes/location-picker/blob/master/docs/csp.md)).
## Geocoders
| Provider | Paired geocoder | `pairsWith` |
| --------------------- | ----------------------------------------------- | ------------------------- |
| `leaflet`, `maplibre` | `photonGeocoder()` | `['leaflet', 'maplibre']` |
| `google` | `googleGeocoder()` + `PlaceAutocompleteElement` | `['google']` |
Search and reverse geocoding are off until you set `search` or `reverse-geocode` (or call `geocode()` / `lookupAddress()`). Coordinates never need a geocoder: in the built-in search box (Leaflet and MapLibre), typed or pasted coordinates show one offline "Go to …" option, and on Google a pasted coordinate jumps directly.
### Photon
[Photon](https://photon.komoot.io/) (by komoot) is a keyless OpenStreetMap geocoder that supports search-as-you-type. The public endpoint is free under fair use, with no SLA, and heavy use is throttled. The picker is gentle with it: queries need 3 characters, are debounced by at least 300 ms on the public endpoint, cancel the previous request, are cached (100 searches, 200 reverse lookups) and return 5 results biased to the map centre. Reverse lookups run 400 ms after a commit.
For production, self-host Photon or use a commercial Photon endpoint:
```ts
import { LocationPicker } from 'location-picker';
import { photonGeocoder } from 'location-picker/photon';
new LocationPicker('#map', {
search: true,
reverseGeocode: true,
geocoder: photonGeocoder({
url: 'https://photon.example.org/api',
reverseUrl: 'https://photon.example.org/reverse',
params: { osm_tag: 'place' },
}),
});
```
On the element, set the property: `el.geocoder = photonGeocoder({ url })`. With the script build, the factory is `LocationPicker.photonGeocoder`.
### Custom geocoders
A geocoder is an object with `search` and/or `reverse` (a bare function acts as `search`). Declare `pairsWith` so it is only used with maps it may appear on:
```ts
import { LocationPicker, type Geocoder, type GeocodeResult } from 'location-picker';
// For example, your own server-side endpoint in front of a commercial geocoder.
const myGeocoder: Geocoder = {
pairsWith: ['leaflet', 'maplibre'],
async search(query, { signal, near, limit, lang }) {
const params = new URLSearchParams({
q: query,
lat: String(near.lat),
lng: String(near.lng),
limit: String(limit),
lang,
});
const response = await fetch(`/api/geocode?${params}`, { signal });
return (await response.json()) as GeocodeResult[];
},
async reverse({ lat, lng }, { signal, lang }) {
const response = await fetch(`/api/reverse?lat=${lat}&lng=${lng}&lang=${lang}`, { signal });
return (await response.json()) as GeocodeResult | null;
},
};
new LocationPicker('#map', { search: true, reverseGeocode: true, geocoder: myGeocoder });
```
A geocoder without `pairsWith` works with every provider; then you are responsible for its terms, and non-Google results must never appear on a Google map. `geocoder: false` turns geocoding off (`geocode()` and `lookupAddress()` then reject with `GEOCODER_MISSING`).
Nominatim is never a default here and has no example in these docs. Its usage policy, https://operations.osmfoundation.org/policies/nominatim/, forbids client-side autocomplete, allows at most one request per second and requires caching; read it before writing a custom geocoder that calls Nominatim, and do not use it for the search box.
## Switching providers on the element
Setting `provider`, `api-key`, `map-id`, `map-style`, `engine-url`, `engine-css-url`, `marker="advanced"`, `engine` or `mapOptions` rebuilds the map (several changes in one task rebuild once). The coordinates are kept, `el.ready` is replaced immediately, and `ready` fires again:
```ts
const el = document.querySelector('location-picker')!;
el.provider = 'google';
el.apiKey = 'YOUR_BROWSER_KEY';
const ready = await el.ready;
console.log(ready.map);
```
When the provider name changes (for example from `google` to `leaflet`), the address is cleared silently, so Google text never stays next to an OpenStreetMap map. With `reverse-geocode` on, the new provider's geocoder looks the address up again.
## Your own engine
Any map library can be plugged in through the public `ProviderAdapter` contract. An OpenLayers adapter in about 20 lines:
```ts
import { LocationPicker, type ProviderAdapter } from 'location-picker/core';
import OlMap from 'ol/Map'; import View from 'ol/View'; import TileLayer from 'ol/layer/Tile'; import OSM from 'ol/source/OSM';
import { fromLonLat, toLonLat } from 'ol/proj';
const openlayers: ProviderAdapter<OlMap> = {
name: 'openlayers',
mount({ container, view, events, signal }) {
const map = new OlMap({ target: container, layers: [new TileLayer({ source: new OSM() })],
view: new View({ center: fromLonLat([view.center.lng, view.center.lat]), zoom: view.zoom }) });
const center = () => { const [lng, lat] = toLonLat(map.getView().getCenter()!); return { lat, lng }; };
map.getView().on('change:center', () => events.move(center()));
map.on('moveend', () => { events.moveEnd(center(), map.getView().getZoom()!); events.interactionEnd(); });
container.addEventListener('pointerdown', () => events.interactionStart(), { capture: true, signal });
return {
map, getCenter: center, getZoom: () => map.getView().getZoom()!,
setView: (c, z, { animate }) => map.getView().animate({ center: fromLonLat([c.lng, c.lat]), zoom: z, duration: animate ? 250 : 0 }),
setZoom: (z) => map.getView().setZoom(z),
setInteractive: (m) => map.getInteractions().forEach((i) => i.setActive(m === 'full')),
resize: () => map.updateSize(), destroy: () => map.setTarget(undefined),
};
},
};
new LocationPicker('#map', { provider: openlayers, value: '48.8584,2.2945' });
```
The adapter creates its map inside `container` only, reports camera changes through `events`, and the core does rounding, de-duplication and event sources. The member reference is in [api.md](https://github.com/cyphercodes/location-picker/blob/master/docs/api.md#provideradapter).
---
Source: https://github.com/cyphercodes/location-picker/blob/master/docs/csp.md
# Content Security Policy
location-picker is built to run under a strict Content Security Policy:
- **Its own CSS needs no `style-src` entry.** The element adopts one constructed stylesheet into its shadow root, and the class adopts one into its target's document (or shadow root). Neither uses `style=""` attributes or inline `<style>` (except the fallback described below).
- **Everything it inserts carries your nonce**: the Leaflet `<script>` and `<link>`, Google's bootstrap, and the fallback `<style>`. The nonce comes from the `nonce` attribute or option, or by default from the first `script[nonce]` in the document.
- **It loads nothing you did not ask for.** By default that is Leaflet from jsDelivr (only when `window.L` is absent) and the OpenStreetMap tiles. Search and reverse geocoding add Photon. MapLibre and its CSS are never fetched unless you set a URL.
## Recipes
| Setup | `script-src` | `style-src` | `img-src` | `connect-src` | Other |
| ------------------------------------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------ | ------------------------------- | -------------------------------------- |
| Default: Leaflet auto-load + OSM | `https://cdn.jsdelivr.net` (or `'nonce-…' 'strict-dynamic'`) | `https://cdn.jsdelivr.net` | `https://tile.openstreetmap.org` | – | – |
| + `search` / `reverse-geocode` (Photon) | as above | as above | as above | `https://photon.komoot.io` | – |
| Self-hosted engine (`engine-url="/vendor/leaflet/leaflet.js"`) or a bundled `L` | `'self'` | `'self'` | your tile host | your Photon host | – |
| MapLibre (bundled) + OpenFreeMap | `'self'` | `'self'` (or jsDelivr, only if you set `engine-css-url` to it) | `'self' data: blob:` | `https://tiles.openfreemap.org` | `worker-src blob:` (MapLibre's worker) |
| Google | per Google's guide (nonce + `'strict-dynamic'`) | Google injects `<style>` into the page and into the element's shadow root: follow Google's guide | `https://*.googleapis.com https://*.gstatic.com data:` | `https://*.googleapis.com` | follow Google's guide for fonts |
| Class mode where `adoptedStyleSheets` is unavailable | – | `'nonce-…'` (the fallback `<style nonce>`), or `injectStyles: false` + `style.css` | – | – | – |
If you load location-picker itself from jsDelivr (`https://cdn.jsdelivr.net/npm/location-picker@3`), that is already covered by the `script-src` entry of the default row. Self-hosting the script build means `'self'`.
## Examples
The default setup, with search, loading everything from jsDelivr:
```text
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' https://cdn.jsdelivr.net; img-src 'self' https://tile.openstreetmap.org; connect-src 'self' https://photon.komoot.io
```
The same with a nonce instead of host allowlists (`RANDOM` is a fresh value per response, also set on your own `<script nonce="RANDOM">` tags):
```text
Content-Security-Policy: script-src 'nonce-RANDOM' 'strict-dynamic'; style-src 'self' https://cdn.jsdelivr.net; img-src 'self' https://tile.openstreetmap.org; connect-src 'self' https://photon.komoot.io; object-src 'none'; base-uri 'none'
```
Everything self-hosted, with your own tiles and Photon:
```text
Content-Security-Policy: default-src 'self'; img-src 'self' https://tiles.example.com; connect-src 'self' https://photon.example.org
```
```html
<location-picker
name="location"
search
engine-url="/vendor/leaflet/leaflet.js"
engine-css-url="/vendor/leaflet/leaflet.css"
tile-url="https://tiles.example.com/{z}/{x}/{y}.png"
attribution="© OpenStreetMap contributors"
></location-picker>
```
With a bundler, pass the engine and import Leaflet's CSS yourself; nothing is fetched from a CDN (see the [JavaScript class example](https://github.com/cyphercodes/location-picker/blob/master/README.md#javascript-class)).
## Class mode and `style.css`
The class adopts its stylesheet into the target's root node. Where constructable stylesheets are unavailable it falls back to a `<style>` element that carries the nonce. To add no styles at runtime at all, turn injection off and import the stylesheet from your bundle:
```ts
import 'location-picker/style.css';
import { LocationPicker } from 'location-picker/core';
new LocationPicker('#map', { injectStyles: false });
```
`location-picker/style.css` also contains a placeholder rule that reserves 320px for `<location-picker>` before the script runs, which avoids layout shift.
## Google Maps
Google's Maps JavaScript API has its own requirements, which change over time. Follow Google's [Content Security Policy guide](https://developers.google.com/maps/documentation/javascript/content-security-policy) (a nonce with `'strict-dynamic'`). The picker puts your nonce on the bootstrap script it injects, so Google's loader picks it up.
## Trusted Types
The engine auto-loader does not support `require-trusted-types-for 'script'` in 3.0. Load the engine yourself (a bundled `L` or your own `<script>`) and set `engine-url="none"`.
---
Source: https://github.com/cyphercodes/location-picker/blob/master/docs/migration.md
# Migrating to v3
v3 keeps every v1 and v2 entry point working, but the new API is built around a form field: `<location-picker>` plus an imperative `LocationPicker` class, on OpenStreetMap by default. This page lists every v1 and v2 API, what happens to it, and the one-line fixes.
## Which path are you on?
| You use | What to do |
| -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `<script src="https://unpkg.com/location-picker/dist/location-picker.min.js">` (unpinned v1) | Nothing. Since 2.1.0 that path serves a frozen copy of v1 again. To get v3, follow [v1 script tag to v3](#v1-script-tag-to-v3). |
| npm 1.x: `import locationPicker from 'location-picker'` | Change the import to `location-picker/v1`. Behaviour is identical. |
| npm 1.x CommonJS: `new (require('location-picker'))(…)` | `require('location-picker/v1')`. |
| npm 2.x: `import { LocationPicker } from 'location-picker'` with Google Maps | Pass `provider: googleProvider()` (or an `apiKey`). See [v2 to v3](#v2-to-v3). |
| A vendored copy of the v1 file | Nothing; it keeps working. v1 and v3 can run on the same page. |
The frozen v1 file shares no code with v3. It only gained three bug fixes: longitude is wrapped into [-180, 180), latitude is clamped (#12), and `0` is a valid `lat`/`lng` (#11). The pin is an SVG with the same 28×44 footprint and anchor as the old PNG.
## Every v1 and v2 API
| v1 (1.1.1) / v2 (2.0.0) API | v3 | Status |
|---|---|---|
| `<script src=…/location-picker/dist/location-picker.min.js>`; global `locationPicker`; AMD | The same path serves the **frozen v1 file** (standalone source) | **Kept forever.** The 42 unpinned unpkg sites work again from 2.1.0 (D17) |
| `new locationPicker(el, {setCurrentPosition, lat, lng}, mapOptions)`; sync Google `.map`; `.element`; auto CSS; `.location-picker .centerMarker` 28×44; Tripoli; zoom 15 | Frozen v1 file. npm users: `import locationPicker from 'location-picker/v1'` | Kept. The only fixes are lng wrap/lat clamp (#12) and 0 as a valid lat/lng (#11) |
| `google.maps.event.addListener(lp.map,'idle',…)` | v1 file: unchanged. v3: `change` events (or `idle` on `picker.map` with `provider: googleProvider()`) | Kept / superseded |
| `new (require('location-picker'))()` (v1 CJS) | `require('location-picker/v1')` | Alias (one-line change) |
| `import LocationPicker from 'location-picker'` / `{ LocationPicker }` (v2) | Same names; `default` = v3 class | Kept |
| `require('location-picker')` → `{ LocationPicker, default }` (v2) | Same shape | Kept |
| v2 ctor `(element, options, mapOptions)` | Same signature. The 3rd argument merges into `mapOptions` and selects `google` (deprecation warning) | Kept |
| `getMarkerPosition()` | Same. Normalised, never a fake `{0,0}`. While unset it returns the view centre and warns once (read `valueAsLatLng`) | Kept (bug fixed) |
| `setLocation(lat, lng)` | Same, plus a `LatLngInput` overload; now commits (`change`, source `api`). Out-of-range latitude is clamped with a warning, as Google clamped it in v2 (D19) | Kept |
| `setCurrentPosition()` (v1 void / v2 Promise) | Alias of `locate()` that keeps the current zoom, like v2 (`locate()` itself zooms to max(current, 17)): `Promise<LocationDetail>` (⊇ LatLng). Rejects `LocationPickerError` with `.code`; never hangs | Kept (v2 shape) |
| Option `setCurrentPosition` (default **true**) | `locate: 'auto'` when `true`. The default is now `'off'` | **Default changed** (LOCKED 7) |
| Options `lat`, `lng` | Deprecated aliases of `value` (both required; 0 valid; latitude clamped with a warning, D19; NOT a provider signal) | Alias |
| `onLocationChange(pos)` | Called like `onChange` (de-duplicated; the detail is a superset of LatLng). Like v2's first `idle`, also called ONCE after `ready` with the current value (source `api`), but only when a value is set: v2 always had a position (Tripoli or geolocation), while an unset v3 picker has none, and reporting the 20,0 world view would fill hidden inputs with a fake location | **Changed** (not called after `ready` while unset) |
| `useAdvancedMarker` | `marker: 'advanced'` (Google; needs `mapId`, else `MAP_ID_REQUIRED` + CSS pin); also selects `google` | Alias |
| `destroy()` | Removes the created map, every listener and every node | Kept (bug fixed) |
| `import 'location-picker/style.css'` | Still exported (now optional: auto-adopt is the default), with a types stub | Kept |
| `.map` (sync `google.maps.Map`, v1/v2) | `undefined` until `ready`; SYNC with `provider: googleProvider()` on a page with classic Google loaded | **Break** (one-line fix) |
| Implicit Google provider (v2) | Google is selected by `apiKey`/`mapId`/`useAdvancedMarker`/3rd argument. Otherwise OSM, with a console hint | **Break** (zero public v2 users found) |
| Tripoli fallback | World view (20, 0) at zoom 2, unset | Removed (#7); kept in the v1 file |
| `.centerMarker` / PNG pin | Class mode: `.lp-pin.centerMarker` (alias kept). Element: `::part(pin)`. SVG + CSS variables | Changed (the v1 file keeps the PNG/SVG v1 look) |
| v2 d.ts (global `google`, CSS import, extensionless specifiers) | Fixed per LOCKED 10 | Fixed (#2) |
| `"type": "module"`, `engines.node >= 18` | `"type": "commonjs"` with `.mjs`/`.cjs`; no `engines` | Changed (deep `.mjs`/`.cjs` paths kept) |
| GPL `LICENSE` text | MIT; the README/CHANGELOG grant MIT for all prior versions too | Fixed (LOCKED 1) |
(D17, D19, LOCKED 1, 7 and 10 refer to decisions in the v3 design record; the table is copied from it unchanged.)
## Breaks, changed defaults and changed behaviour
- **`.map` is `undefined` until `ready`** (Break). v1 and v2 created the Google map synchronously in the constructor. In v3 the engine loads first. Wait for `await picker.ready`, use the `ready` event, or pass an adapter object on a page that already loaded Google Maps with a classic script tag: then `picker.map` is set before the constructor returns.
- **Google is no longer implicit** (Break). Without `provider: 'google'`, `apiKey`, `mapId`, `useAdvancedMarker` or a 3rd constructor argument, v3 uses OpenStreetMap. A loaded `google.maps` global is never sniffed; it only triggers a one-time console hint.
- **No location prompt on load** (Default changed). The `setCurrentPosition` option defaulted to `true` in v1 and v2. In v3 the locate control defaults to `'off'`; `setCurrentPosition: true` still maps to `locate: 'auto'`. Prefer `locate: 'button'`, which prompts only when clicked.
- **`onLocationChange`** (Changed) is called like `onChange`, plus once after `ready` when a value is set. An unset picker does not report the world view as a position.
- **The pin** (Changed) is an SVG themed with `--lp-pin-color`, `--lp-pin-size` and friends. In class mode it keeps the `centerMarker` class, so `.location-picker .centerMarker { display: none }` still hides it. On the element, use `location-picker::part(pin)`.
- **Package format** (Changed): `"type": "commonjs"` with `.mjs`/`.cjs` files and no `engines` field. `import` and `require` resolve as before.
- **The Tripoli fallback is gone** (Removed). With no `value` and no `center`, v3 shows a world view and stays unset.
- **`setLocation()` commits**: it fires `input` and `change` (source `api`).
- **`getMarkerPosition()` while unset** returns the view centre and warns once. Read the user's choice from `value` (`""` while unset) or `valueAsLatLng` (`null` while unset).
## v1 script tag to v3
v1 pages typically created the picker, listened for Google's `idle` event and copied the position into hidden inputs:
```html
<div id="map" style="height: 400px"></div>
<input type="hidden" name="lat" id="lat" /><input type="hidden" name="lng" id="lng" />
<script src="https://maps.googleapis.com/maps/api/js?key=YOUR_BROWSER_KEY"></script>
<script src="https://unpkg.com/location-picker/dist/location-picker.min.js"></script>
<script>
var lp = new locationPicker('map', { setCurrentPosition: true }, { zoom: 15 });
google.maps.event.addListener(lp.map, 'idle', function () {
var p = lp.getMarkerPosition();
document.getElementById('lat').value = p.lat;
document.getElementById('lng').value = p.lng;
});
</script>
```
In v3 the element fills the same inputs itself, on OpenStreetMap with no key, and the locate button replaces the automatic prompt:
```html
<form method="post" action="/places">
<location-picker
lat-input="lat"
lng-input="lng"
locate="button"
required
style="height: 400px"
></location-picker>
<input type="hidden" name="lat" id="lat" /><input type="hidden" name="lng" id="lng" />
<button>Save</button>
</form>
<script src="https://cdn.jsdelivr.net/npm/location-picker@3"></script>
```
To stay on Google Maps, add `provider="google" api-key="YOUR_BROWSER_KEY"` (see [providers.md](https://github.com/cyphercodes/location-picker/blob/master/docs/providers.md#google-maps)).
## v2 to v3
The class keeps its v2 name, constructor signature and methods. Map the options:
| v2 | v3 |
| ---------------------------------------- | ------------------------------------------------------------------------ |
| `setCurrentPosition: true` (the default) | `locate: 'auto'`, or better `locate: 'button'` |
| `lat: 45.5, lng: -73.56` | `value: { lat: 45.5, lng: -73.56 }` or `value: '45.5,-73.56'` |
| `onLocationChange: (p) => …` | `onChange: (detail) => …` (`detail` has `lat`, `lng`, `value`, `source`) |
| `useAdvancedMarker: true` | `marker: 'advanced'` with `mapId` (Google only) |
| 3rd argument `{ zoom: 3, … }` | `zoom: 3` and `mapOptions: { … }`, with `provider: 'google'` |
| implicit Google | `provider: googleProvider()` or `apiKey` |
| `import 'location-picker/style.css'` | optional; styles are adopted automatically |
A v2 Google picker that needs `picker.map` right away:
```ts
import { LocationPicker } from 'location-picker/core';
import { googleProvider } from 'location-picker/google';
const picker = new LocationPicker<google.maps.Map>('map', {
provider: googleProvider(),
value: { lat: 45.5, lng: -73.56 },
locate: 'button',
onChange: (d) => console.log(d.source, d.lat, d.lng),
});
picker.map?.setOptions({ mapTypeId: 'hybrid' }); // set synchronously when Google Maps is already loaded
```
The same picker on OpenStreetMap, without a key:
```ts
import { LocationPicker } from 'location-picker';
const picker = new LocationPicker('#map', {
value: '45.5,-73.56',
locate: 'button',
onChange: (d) => console.log(d.value),
});
await picker.ready;
```
`setCurrentPosition()` still returns a promise, now with a `LocationDetail`. It rejects with a `LocationPickerError` whose `code` is `GEOLOCATION_DENIED`, `GEOLOCATION_UNAVAILABLE`, `GEOLOCATION_TIMEOUT`, `GEOLOCATION_UNSUPPORTED` or `ABORTED`, and it never hangs.
## v1 and v3 on the same page
The frozen v1 file defines the lowercase global `locationPicker`; the v3 script build defines `LocationPicker` and `<location-picker>`. Both can be loaded on one page, so a site can migrate one form at a time.
## License
From 2.1.0 the package ships the MIT License text, and the author grants the MIT License for all prior versions of location-picker as well.
---
Source: https://github.com/cyphercodes/location-picker/blob/master/docs/troubleshooting.md
# Troubleshooting
Problems are reported loudly. Failures fire an `error` event whose detail is a `LocationPickerError` with a stable `code` and a one-line `fix`; if nothing handles the event, it is logged as `[location-picker] <what happened>. Fix: <fix>`. Configuration slips (unknown options, misspelled attributes, a clamped latitude) are one-time `console.warn` lines. Every error code and its fix is listed in [errors.md](https://github.com/cyphercodes/location-picker/blob/master/docs/errors.md).
To handle errors yourself, listen for `error` and call `preventDefault()` to stop the console log, or pass an `onError` callback to the class:
```html
<location-picker id="loc" name="location"></location-picker>
<script src="https://cdn.jsdelivr.net/npm/location-picker@3"></script>
<script>
document.getElementById('loc').addEventListener('error', (e) => {
e.preventDefault(); // handled here, so it is not logged
console.warn(e.detail.code, e.detail.fix);
});
</script>
```
Promises (`ready`, `locate()`, `geocode()`, `lookupAddress()`) reject with the same error type instead of firing `error`.
## `<location-picker>` stays empty
- **Only `location-picker/core` was imported.** `core` is the side-effect-free class. `import 'location-picker'` (or `location-picker/element`, or the script tag) defines the element.
- **The script URL is the v1 file.** `…/location-picker/dist/location-picker.min.js` without a version is the frozen v1 build, which defines the global `locationPicker` and no element. Use `https://cdn.jsdelivr.net/npm/location-picker@3`.
- **The engine failed to load** (`ENGINE_LOAD_FAILED`, `ENGINE_MISSING`, `API_KEY_MISSING`). The picker then shows an error message and a text input for typing coordinates, so the form stays usable. Put your own content in `slot="error"`.
## The map is grey, blank or unstyled
- **Leaflet's CSS did not apply** (`ENGINE_CSS_MISSING`). Load `leaflet.css` on the page, set `engine-css-url`, or allow `https://cdn.jsdelivr.net` in your CSP `style-src` ([csp.md](https://github.com/cyphercodes/location-picker/blob/master/docs/csp.md)).
- **Tiles are blocked.** Check the network panel and your CSP `img-src`. Tile failures leave the map interactive, and commits still work.
- **The class target has no height.** A `LocationPicker` target that measures 0 px tall gets `min-block-size: 320px` and a one-time warning. Give it a height. The element is 320px tall by default.
- **Inside tabs, a `<dialog>` or `display: none`.** Nothing to do: the picker resizes and re-centres on the value when it becomes visible.
## `locationPicker is not defined` or `LocationPicker is not defined`
The lowercase `locationPicker` global belongs to the v1 file (`dist/location-picker.min.js`). The v3 script build (`location-picker@3`) defines `LocationPicker` (capital L) and the `<location-picker>` element. Both can be on one page.
## Nothing is submitted
- Give the picker a `name` (or `lat-name`/`lng-name`), or bind existing inputs with `lat-input`/`lng-input`.
- A `disabled` picker, or one inside `<fieldset disabled>`, submits nothing, like any input.
- jQuery `.serialize()` skips custom elements. Use bound inputs or `new FormData(form)`.
- An unset picker submits an empty value (`location=`). Add `required` to make the user choose.
## The form does not submit, and the browser complains about a control that is "not focusable"
A hidden input has `required`. Remove it and put `required` on `<location-picker>`.
## The value ignores my `lat`/`lng` bindings
`lat` and `lng` set the initial value only. Once the control is dirty (any pan, commit or `value` write, including the `el.value = ''` that Vue `v-model` and Angular `writeValue` perform on mount), changing them is ignored with the warning "lat/lng only set the initial value; bind value="lat,lng" or call setLocation()". Bind `value` instead.
## The value looks swapped or clamped
- Everything is **latitude first**. `"2.2945,48.8584"` is a valid point in the Indian Ocean, not Paris; the picker cannot detect that swap.
- A string with a latitude outside [-90, 90] is rejected with `INVALID_VALUE` and a hint such as `did you swap lat and lng? try "45,120"`.
- Numbers and objects are clamped instead: `setLocation(95, 10)` commits `"90,10"` and warns "latitude 95 was clamped to 90; did you swap lat and lng? try (10, 95)".
- Arrays are rejected (`INVALID_VALUE`), because `[lat, lng]` and GeoJSON's `[lng, lat]` are ambiguous.
- A `maps.app.goo.gl` short link cannot be expanded in the browser: open it and copy the full URL.
## A pan stops halfway and no `change` fires
Something wrote `value` while the map was moving. In 3.0 a write during a drag or an arrow-key pan stops the move without a commit. The usual cause is a framework binding that writes the live value back from `input` (`value` with `onInput`, `oninput`, `on:input` or `@input`). Update your state from `change` instead; Vue's `v-model` is not affected. See [frameworks.md](https://github.com/cyphercodes/location-picker/blob/master/docs/frameworks.md).
## Field mode: `ready` never fires, or the field is very tall
- In field mode (`presentation="field"`, or `"auto"` on a phone) the map engine loads when the sheet first opens, so `el.ready`, `el.map` and the `ready` event wait for that. Do not gate the form on `ready`; methods such as `setLocation()` and `locate()` still work while the sheet is closed.
- A height set on the element (`style="height: 400px"`) also applies to the compact field. Leave it off, or set it in a `@media` rule that excludes phones.
- `showPicker()` does nothing and warns in inline mode: set `presentation="field"` or `"auto"`.
## `getMarkerPosition()` warns or returns 20,0
While the picker is unset, `getMarkerPosition()` returns the view centre (the 20,0 world view by default), not a chosen location, and warns once. Read `value` (`""` while unset) or `valueAsLatLng` (`null` while unset).
## React: handlers or properties are ignored
- `onError`, `onAddressChange` and `onReady` never fire on the element. Use the lowercase `onerror`, `onaddresschange` and `onready`; `location-picker/jsx/react` flags the wrong spelling as a type error.
- After `hydrateRoot` (Next.js App Router, Remix), React applies neither object props (`messages`, `geocoder`, …) nor lowercase native handlers. Set them in a ref callback: see the [React hydration recipe](https://github.com/cyphercodes/location-picker/blob/master/docs/frameworks.md#react-19-and-nextjs).
- React 18 sets attributes rather than properties, so binding `value` needs React 19.
## Google Maps
- **The picker shows OpenStreetMap although Google is loaded.** Google is never chosen implicitly. Add `provider="google"` (or `api-key`). The console hint `google.maps is loaded but no provider was chosen` says the same.
- **`API_KEY_MISSING`.** Add `api-key="YOUR_KEY"`, or load the Google Maps JS API before the picker.
- **`GOOGLE_AUTH_FAILED`.** Check the key's HTTP-referrer restrictions, billing, and that the Maps JavaScript API is enabled.
- **`MAP_ID_REQUIRED`.** `marker="advanced"` needs `map-id`; the CSS pin is used until then.
- **Search does nothing** (`GEOCODER_FAILED`). Enable Places API (New) for the key. On old pages that load Google with a classic script tag and no `importLibrary`, add `libraries=places`.
- **A pinch zoom changed the value.** On Google Maps a pinch zooms around the fingers, so the centre moves and the new centre is committed. Wheel, double-click and the zoom buttons stay anchored at the pin.
## The wheel scrolls the page instead of zooming
With the default `gesture-handling="auto"`, a page that scrolls gets cooperative gestures, so the map does not trap the scroll inside a form. Hold Ctrl (⌘ on Apple platforms) while scrolling, use the zoom buttons, or set `gesture-handling="greedy"`.
## Geolocation
- **The locate button is missing.** It is hidden when the page is not a secure context (use HTTPS or localhost) or the browser has no geolocation. In an iframe, the frame needs `allow="geolocation"`.
- **`GEOLOCATION_DENIED`, `GEOLOCATION_UNAVAILABLE`, `GEOLOCATION_TIMEOUT`.** `locate()` rejects with these codes. It never hangs: it uses a 10 s `timeout` plus a watchdog 30 s later, which also covers an unanswered permission prompt. Adjust with the `geolocation` property (`{ timeout: 5000 }`).
- **Nothing prompted on load.** That is the default. Use `locate="button"` (prompts on click) or `locate="auto"` (also tries once on load while unset).
## Search returns nothing
- Queries need at least 3 characters. Coordinates (`48.85, 2.29`, a map URL) jump directly without a request.
- The public Photon endpoint is fair-use and throttles heavy use. For production, self-host Photon or use a commercial endpoint ([providers.md](https://github.com/cyphercodes/location-picker/blob/master/docs/providers.md#photon)).
- Check your CSP `connect-src` for `https://photon.komoot.io`.
- `GEOCODER_MISMATCH` means the geocoder is not allowed with this map provider (for example Photon results on a Google map). Use the provider's own geocoder or a custom one with a matching `pairsWith`.
## Two copies of the library on one page
The ESM build, the CommonJS build and the script build share one global store, so Leaflet and Google are loaded once and the element is defined once (a warning appears if the versions differ). `instanceof LocationPickerElement` can still be false across copies; check with `customElements.get('location-picker')` or duck typing.
## Component tests in jsdom or happy-dom
These environments lack form internals, `ResizeObserver` and constructable stylesheets. The element then runs inert: it logs one message naming the missing API, never throws, and `value`, `valueAsLatLng` and `setLocation()` still round-trip. Test maps and forms in a real browser.
## Still stuck?
Open an issue with the `code` and `fix` from the console, how you load the library (script tag, npm entry), the provider, and a minimal page that reproduces it: https://github.com/cyphercodes/location-picker/issues
---
Source: https://github.com/cyphercodes/location-picker/blob/master/docs/errors.md
# Error codes
Every failure is a `LocationPickerError` with a stable `code`, a one-line `fix` and a `level`. Its message reads
`[location-picker] <what happened>. Fix: <fix>`.
- Failures that are not tied to a returned promise fire a cancelable `error` event (it does not bubble) on the
element or the class instance. When no listener calls `preventDefault()` and no `onError` callback exists, it is
logged at its `level`.
- Methods (`locate()`, `geocode()`, `lookupAddress()`) and the `ready` promise reject with the same error instead.
- `{placeholders}` in a message are filled in with the details of the failure.
The level below is the default for each code; a few cases override it (see
[`LocationPickerErrorCode`](https://github.com/cyphercodes/location-picker/blob/master/docs/api.md#locationpickererrorcode)).
| Code | Level | Fix |
| ----------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------- |
| [`TARGET_NOT_FOUND`](#target_not_found) | `error` | Pass an element, an id ("map") or a selector ("#map") that exists when the picker is created. |
| [`INVALID_VALUE`](#invalid_value) | `warn` | Pass "lat,lng" (latitude first), e.g. "48.8584,2.2945", or {lat, lng}. |
| [`INVALID_OPTION`](#invalid_option) | `error` | Check the option name and value against the LocationPickerOptions docs. |
| [`ENGINE_MISSING`](#engine_missing) | `error` | Load Leaflet first, pass engine: L, or remove engine-url="none". |
| [`ENGINE_LOAD_FAILED`](#engine_load_failed) | `error` | Check the network and the CSP script-src, or self-host the engine and set engine-url. |
| [`ENGINE_CSS_MISSING`](#engine_css_missing) | `warn` | Load leaflet.css on the page, set engine-css-url, or allow https://cdn.jsdelivr.net. |
| [`API_KEY_MISSING`](#api_key_missing) | `error` | Add api-key="YOUR_KEY", or load the Google Maps JS API before the picker. |
| [`GOOGLE_AUTH_FAILED`](#google_auth_failed) | `error` | Check the key's HTTP-referrer restrictions, billing, and that Maps JavaScript API is enabled. |
| [`MAP_ID_REQUIRED`](#map_id_required) | `warn` | Set map-id (mapId), or use marker="pin". |
| [`GEOCODER_MISMATCH`](#geocoder_mismatch) | `error` | Use the provider's paired geocoder; Google's terms forbid mixing Google and non-Google content. |
| [`GEOCODER_MISSING`](#geocoder_missing) | `error` | Remove geocoder: false, or pass a geocoder. |
| [`GEOCODER_FAILED`](#geocoder_failed) | `warn` | Check the network and the service's rate limits; use your own geocoder in production. |
| [`GEOLOCATION_UNSUPPORTED`](#geolocation_unsupported) | `warn` | Serve the page over HTTPS, and add allow="geolocation" to a containing iframe. |
| [`GEOLOCATION_DENIED`](#geolocation_denied) | `warn` | Allow location access in the browser's site settings, or pick the point on the map. |
| [`GEOLOCATION_UNAVAILABLE`](#geolocation_unavailable) | `warn` | Try again (Wi-Fi helps), or pick the point on the map. |
| [`GEOLOCATION_TIMEOUT`](#geolocation_timeout) | `warn` | Try again, or raise geolocation.timeout. |
| [`ABORTED`](#aborted) | `silent` | Nothing to fix: the picker was destroyed or a newer request replaced this one. |
## `TARGET_NOT_FOUND`
- **Level:** `error` (logged with `console.error` when unhandled)
- **Message:** no element matches {target}
- **Fix:** Pass an element, an id ("map") or a selector ("#map") that exists when the picker is created.
## `INVALID_VALUE`
- **Level:** `warn` (logged with `console.warn` when unhandled)
- **Message:** {value} is not a coordinate
- **Fix:** Pass "lat,lng" (latitude first), e.g. "48.8584,2.2945", or {lat, lng}.
## `INVALID_OPTION`
- **Level:** `error` (logged with `console.error` when unhandled)
- **Message:** invalid option {option}
- **Fix:** Check the option name and value against the LocationPickerOptions docs.
## `ENGINE_MISSING`
- **Level:** `error` (logged with `console.error` when unhandled)
- **Message:** the map engine is not loaded
- **Fix:** Load Leaflet first, pass engine: L, or remove engine-url="none".
## `ENGINE_LOAD_FAILED`
- **Level:** `error` (logged with `console.error` when unhandled)
- **Message:** the map engine could not be loaded from {url}
- **Fix:** Check the network and the CSP script-src, or self-host the engine and set engine-url.
## `ENGINE_CSS_MISSING`
- **Level:** `warn` (logged with `console.warn` when unhandled)
- **Message:** the map engine CSS is missing
- **Fix:** Load leaflet.css on the page, set engine-css-url, or allow https://cdn.jsdelivr.net.
## `API_KEY_MISSING`
- **Level:** `error` (logged with `console.error` when unhandled)
- **Message:** Google Maps needs an API key
- **Fix:** Add api-key="YOUR_KEY", or load the Google Maps JS API before the picker.
## `GOOGLE_AUTH_FAILED`
- **Level:** `error` (logged with `console.error` when unhandled)
- **Message:** Google Maps rejected the API key
- **Fix:** Check the key's HTTP-referrer restrictions, billing, and that Maps JavaScript API is enabled.
## `MAP_ID_REQUIRED`
- **Level:** `warn` (logged with `console.warn` when unhandled)
- **Message:** marker="advanced" needs a Google map ID; showing the CSS pin
- **Fix:** Set map-id (mapId), or use marker="pin".
## `GEOCODER_MISMATCH`
- **Level:** `error` (logged with `console.error` when unhandled)
- **Message:** this geocoder may not be shown on a {provider} map
- **Fix:** Use the provider's paired geocoder; Google's terms forbid mixing Google and non-Google content.
## `GEOCODER_MISSING`
- **Level:** `error` (logged with `console.error` when unhandled)
- **Message:** no geocoder is available
- **Fix:** Remove geocoder: false, or pass a geocoder.
## `GEOCODER_FAILED`
- **Level:** `warn` (logged with `console.warn` when unhandled)
- **Message:** the geocoding request failed
- **Fix:** Check the network and the service's rate limits; use your own geocoder in production.
## `GEOLOCATION_UNSUPPORTED`
- **Level:** `warn` (logged with `console.warn` when unhandled)
- **Message:** geolocation is not available here
- **Fix:** Serve the page over HTTPS, and add allow="geolocation" to a containing iframe.
## `GEOLOCATION_DENIED`
- **Level:** `warn` (logged with `console.warn` when unhandled)
- **Message:** location permission was denied
- **Fix:** Allow location access in the browser's site settings, or pick the point on the map.
## `GEOLOCATION_UNAVAILABLE`
- **Level:** `warn` (logged with `console.warn` when unhandled)
- **Message:** the position could not be determined
- **Fix:** Try again (Wi-Fi helps), or pick the point on the map.
## `GEOLOCATION_TIMEOUT`
- **Level:** `warn` (logged with `console.warn` when unhandled)
- **Message:** finding the position took too long
- **Fix:** Try again, or raise geolocation.timeout.
## `ABORTED`
- **Level:** `silent` (never logged)
- **Message:** the operation was aborted
- **Fix:** Nothing to fix: the picker was destroyed or a newer request replaced this one.
---
Source: https://github.com/cyphercodes/location-picker/blob/master/docs/ai-agents.md
# AI agents
location-picker is written to be used correctly by coding agents as well as people: predictable names, loud errors with fixes, and documentation in the formats agents read.
## For agents writing code that uses location-picker
| Resource | What it is |
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [llms.txt](https://cyphercodes.github.io/location-picker/llms.txt) | A short overview with the key rules, one runnable example and links ([llms.txt format](https://llmstxt.org/)) |
| [llms-full.txt](https://cyphercodes.github.io/location-picker/llms-full.txt) | The README and every page of `docs/` in one file |
| `skills/location-picker/SKILL.md` | An [Agent Skill](https://agentskills.io/), shipped in the npm package (`node_modules/location-picker/skills/location-picker/SKILL.md`) and in the repository |
| `custom-elements.json` | Custom Elements Manifest for the element (the package's `customElements` field): attributes, properties, events, parts, slots, states and CSS properties |
| The `.d.ts` files | JSDoc with `@default` and `@example` on every option; the [API reference](https://github.com/cyphercodes/location-picker/blob/master/docs/api.md) quotes the same text |
| `context7.json` | Indexing configuration for [Context7](https://context7.com/) |
To add the skill to a project with the skills CLI: `npx skills add cyphercodes/location-picker`.
### Rules that prevent most mistakes
1. Load `https://cdn.jsdelivr.net/npm/location-picker@3`, never the unpinned `/dist/location-picker.min.js` (that is the frozen v1 file).
2. `import 'location-picker'` defines `<location-picker>`; `location-picker/core` is the class only.
3. Latitude first: `"lat,lng"` strings or `{ lat, lng }` objects, never arrays.
4. In forms, use `name` (or `lat-name`/`lng-name`), or `lat-input`/`lng-input` for existing inputs. Put `required` on the picker.
5. Bind `value` in frameworks and listen to `change`/`input`. Never bind `lat`/`lng`.
6. Read the result from `value`/`valueAsLatLng` or the `change` event's `detail`, not `getMarkerPosition()`.
7. OpenStreetMap needs no key. Choose Google explicitly (`provider="google"` + `api-key`).
8. In React, the lowercase `onaddresschange`/`onready`/`onerror`; after `hydrateRoot`, set objects and listeners in a ref.
The full list with explanations is the "Common mistakes" section of the [README](https://github.com/cyphercodes/location-picker/blob/master/README.md#common-mistakes).
### Debugging from the console
Every failure carries a stable `code` and a `fix`, in the form `[location-picker] <what happened>. Fix: <fix>`. Misspelled attributes (`latitude=`, `api_key=`, `lon=`, …) and unknown options log a warning that names the right one. The codes are listed in [errors.md](https://github.com/cyphercodes/location-picker/blob/master/docs/errors.md).
## Ask a person for a location: the pick link
An agent, a chat bot, a support team or a no-code automation often needs one exact point from a person: a delivery door, a meeting point, the place of an incident. `https://cyphercodes.github.io/location-picker/pick/` is a hosted page for that, with nothing to install. Send the link; the person moves the map under the pin (or searches, or taps "Use my location") and confirms.
The page is static: no server, no storage, no cookies and no analytics. Everything it knows comes from its URL, and it sends the location only where the URL says, after the person clicks. It uses OpenStreetMap tiles and Photon search. For heavy use, host your own copy: `node scripts/build-site.mjs` (after `npm run build`) builds this MIT-licensed repository's static site into `_site/`, with the page at `pick/`.
| Parameter | Effect |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `lat`, `lng` | A starting point for the person to confirm or adjust |
| `zoom` | Starting zoom, 1 to 19 |
| `q` | Search text; the first result is picked (when no `lat`/`lng` is given) |
| `lang` | Language of search results (for example `fr`) |
| `redirect=<https URL>` | After a click, go there with `lat`, `lng` and (when known) `address` appended; existing query parameters are kept. `https://` only, and the host is shown first |
| `return=postmessage&origin=<origin>` | Post `{ type: 'location-picker:pick', lat, lng, address }` to the window that opened the page or embeds it, only to that exact origin (never `*`) |
Without `redirect` or `return`, the page shows the result with copy buttons (decimal, DMS, `geo:` URI, Google Maps and OpenStreetMap links) and Web Share, so a person can paste it back into a chat. A link that asks for both `redirect` and `postmessage`, or a `postmessage` link without a valid `origin`, sends nothing and says why.
A redirect link and what the destination receives:
```text
https://cyphercodes.github.io/location-picker/pick/?q=Eiffel%20Tower&redirect=https%3A%2F%2Fexample.com%2Forders%2F42%2Flocation
-> https://example.com/orders/42/location?lat=48.85837&lng=2.294481&address=...
```
From a web page, open it in a popup (or an `<iframe>`; add `allow="geolocation"` for the locate button) and listen for the message:
```ts
interface PickMessage {
type: 'location-picker:pick';
lat: number;
lng: number;
address: string | null;
}
const pick = new URL('https://cyphercodes.github.io/location-picker/pick/');
pick.searchParams.set('return', 'postmessage');
pick.searchParams.set('origin', window.location.origin);
pick.searchParams.set('q', 'Eiffel Tower');
window.open(pick, 'pick', 'popup,width=480,height=720');
window.addEventListener('message', (event: MessageEvent<PickMessage>) => {
if (event.origin !== 'https://cyphercodes.github.io') return;
if (event.data?.type !== 'location-picker:pick') return;
console.log(event.data.lat, event.data.lng, event.data.address);
});
```
## WebMCP tools (experimental)
[WebMCP](https://developer.chrome.com/docs/ai/webmcp/imperative-api) lets a page expose tools to an AI agent running in the browser. location-picker can register tools that read and set its location, so an agent does not have to drag a map. It is off by default, feature-detected, and does nothing where WebMCP is unavailable. The WebMCP API itself is still experimental and may change.
Turn it on with the `webmcp` attribute:
```html
<form method="post" action="/deliveries">
<label for="drop">Drop-off point</label>
<location-picker id="drop" name="dropoff" search webmcp style="height: 360px"></location-picker>
<button>Continue</button>
</form>
<script src="https://cdn.jsdelivr.net/npm/location-picker@3"></script>
```
Or call `registerWebMcpTools()` to describe what the location is for:
```ts
import 'location-picker';
import { isWebMcpSupported, registerWebMcpTools } from 'location-picker/webmcp';
const picker = document.querySelector('location-picker');
if (picker && isWebMcpSupported()) {
const registration = registerWebMcpTools(picker, {
purpose: 'Delivery drop-off point for this order.',
});
console.log(registration.tools); // e.g. ['location_picker_get_location', 'location_picker_set_location', …]
}
```
With the `LocationPicker` class, call `registerWebMcpTools(picker)`: in 3.0 the class option `webmcp: true` does not register any tools yet (it arrives in 3.1). The element's `webmcp` attribute works.
| Tool | Input | Result |
| ------------------------ | ---------------------------- | ------------------------------------------------------------------------------------------- |
| `<prefix>_get_location` | none (read-only) | `{ value, lat, lng, address }`, or `{ value: "" }` while unset |
| `<prefix>_set_location` | `{ lat, lng }` or `{ text }` | commits like `setLocation()` (source `api`); `text` is parsed as coordinates, else geocoded |
| `<prefix>_search_places` | `{ query }` (read-only) | up to 5 results, not committed; registered only when a geocoder is available |
- The prefix is `location_picker`, plus `_<id or name>` when the page has several pickers. Override it with `prefix`.
- There is never a locate tool: agents must not trigger permission prompts.
- An out-of-range latitude in `set_location` is a tool error, and nothing is committed.
- `unregister()` or aborting `signal` removes the tools.
## For agents working on this repository
Read [AGENTS.md](https://github.com/cyphercodes/location-picker/blob/master/AGENTS.md): commands, layout, file ownership, test conventions and the rules for generated and frozen files.
---
Source: https://github.com/cyphercodes/location-picker/blob/master/docs/api.md
# API reference
Every public export of `location-picker`, entry point by entry point. The descriptions quote the JSDoc that ships
in the package's `.d.ts` files, so your editor shows the same text on hover.
**Conventions**
- Latitude first, always. A value string is the canonical `"lat,lng"`: dot decimal, no spaces, `precision`
decimals, trailing zeros trimmed, never `-0` (for example `"48.85837,2.294481"`).
- One name per concept: class option `fooBar` = element property `fooBar` = attribute `foo-bar` (the hyphen-less
`foobar` is accepted too). Event `x` = class callback `onX`. The one exception is the locate control: option and
attribute `locate`, readable property `locateMode`, because `locate()` is the method.
- Property writes are silent, like a native `<input>`. User gestures and method calls emit `input` (final value),
then `change`, with `detail.source`.
**Entry points**
| Import | Side effect | Section |
| ----------------------------------------------------- | -------------------------------- | --------------------------------------------------- |
| `location-picker` | defines `<location-picker>` | [root](#location-picker) |
| `location-picker/element` | defines `<location-picker>` | [element](#location-pickerelement) |
| `location-picker/core` | none | [core](#location-pickercore) |
| `location-picker/utils` | none (DOM-free) | [utils](#location-pickerutils) |
| `location-picker/leaflet` | none | [leaflet](#location-pickerleaflet) |
| `location-picker/google` | none | [google](#location-pickergoogle) |
| `location-picker/maplibre` | none | [maplibre](#location-pickermaplibre) |
| `location-picker/photon` | none | [photon](#location-pickerphoton) |
| `location-picker/webmcp` | none | [webmcp](#location-pickerwebmcp) |
| `location-picker/global` | runs the IIFE (`<script>` build) | [global](#location-pickerglobal) |
| `location-picker/style.css` | CSS | [style.css](#location-pickerstylecss) |
| `location-picker/v1` | v1 CSS injection | [v1](#location-pickerv1) |
| `location-picker/dist/location-picker.min.js` | v1 CSS injection | [v1 file](#location-pickerdistlocation-pickerminjs) |
| `location-picker/jsx/{react,preact,solid,vue,svelte}` | none (types only) | [jsx](#location-pickerjsxreact) |
Error codes and their fixes: [errors.md](https://github.com/cyphercodes/location-picker/blob/master/docs/errors.md). Behaviour in forms: [forms.md](https://github.com/cyphercodes/location-picker/blob/master/docs/forms.md).
## `location-picker`
The root entry. `import 'location-picker'`, `import { LocationPicker } from 'location-picker'` and the jsDelivr
`+esm` build all define `<location-picker>` as a side effect (no-op on the server). For a side-effect-free,
class-only import use `location-picker/core`.
CJS: `require('location-picker')` returns `{ LocationPicker, default: LocationPicker, LocationPickerElement, ... }`
(the v2 shape). The v1 shape `new (require(...))()` lives at `require('location-picker/v1')`.
- `export * from 'location-picker/core'`
- `export * from 'location-picker/element'`
- `export { default } from 'location-picker/core'`
## `location-picker/element`
- `export type { ChangeSource }`
### `LocationPickerElementEventMap`
Type: `type LocationPickerElementEventMap = Omit<HTMLElementEventMap, 'input' | 'change' | 'error'> & { input: CustomEvent<LocationDetail>; change: CustomEvent<LocationDetail>; addresschange: CustomEvent<AddressDetail>; ready: CustomEvent<ReadyDetail<unknown>>; error: CustomEvent<LocationPickerError>; }`
Element event map: native HTMLElement events with `input`/`change`/`error` replaced by the picker's CustomEvents.
### `LocationPickerElement`
Class (`extends HTMLElement`).
`<location-picker>`: a form-associated custom element (ElementInternals) that renders into an open shadow root
(`delegatesFocus: true`). On the server it extends an inert stub, so importing it never throws; it is only
defined where `customElements` exists.
Value model: unset (`value === ""`, `:state(unset)`) until the user pans, presses Enter/confirm, picks a search
result or locates, or until `value` is set. The `value` ATTRIBUTE is the default value (applies while not dirty).
Form reset returns to the first of: the `value` attribute, the `lat` + `lng` attributes, the bound inputs' seed
defaults (their server-rendered `value` attributes, captured when the picker first read them; re-reading is not
possible because writing an `<input type="hidden">` rewrites its attribute), unset, and then writes that value into
the bound inputs silently. Live state (`value`, `zoom`, `address`) is
never reflected to attributes. Live state survives a disconnect and reconnect (e.g. Vue `<KeepAlive>`).
In SSR-hydrated React (`hydrateRoot`, e.g. Next.js), React does not apply custom-element PROPERTIES or native
`on*` listeners during hydration: set `messages`, `mapOptions`, `engine`, `geocoder`, `geolocation` and
`addresschange`/`ready`/`error` listeners in a ref callback. Attributes, `value` and `onChange`/`onInput` work.
Field mode (`presentation="field"`, or `"auto"` on phones): a compact control shows the address or coordinates and a
"Choose on map" button; the map opens in a full-screen modal sheet and the value changes only on Confirm. The engine
loads on first open, so `ready` resolves then.
**Attributes:**
- `value`: Default value, lenient ("48.8584, 2.2945", DMS, geo:, map URLs). "," or empty = unset.
- `lat`: Default-value alias, needs `lng` too. `value` wins.
- `lng`: Default-value alias, needs `lat` too.
- `center`: View centre while unset ("lat,lng").
- `zoom`: Initial zoom.
- `max-zoom`
- `precision`
- `name`
- `lat-name`
- `lng-name`
- `address-name`
- `value-input`
- `lat-input`
- `lng-input`
- `address-input`
- `required`
- `disabled`
- `readonly`
- `provider`: leaflet | google | maplibre.
- `api-key`
- `map-id`
- `tile-url`
- `attribution`
- `map-style`
- `engine-url`: URL, or "none" to never auto-load.
- `engine-css-url`: URL, or "none".
- `search`
- `placeholder`
- `reverse-geocode`
- `locate`: off | button | auto ("" = button). Writing the `locate` PROPERTY (as React 19/Vue 3/Preact do for this attribute) also sets it; reading it returns the method.
- `marker`: pin | none | advanced.
- `gesture-handling`
- `webmcp`
- `presentation`: inline | field | auto (field where `(pointer: coarse) and (max-width: 640px)` matches).
**Events:**
- `input`: CustomEvent<LocationDetail>: live user changes, and the final value before every change and at the end of every gesture.
- `change`: CustomEvent<LocationDetail>: committed (user gesture or method call), de-duplicated.
- `addresschange`: CustomEvent<AddressDetail>.
- `ready`: CustomEvent<ReadyDetail>.
- `error`: CustomEvent<LocationPickerError> (cancelable; logged when not canceled).
- `toggle`: ToggleEvent: the field-mode sheet opened (`newState` "open") or closed ("closed").
**CSS parts:** `base`, `search`, `search-input`, `search-listbox`, `search-option`, `map`, `pin`, `pin-icon`, `pin-shadow`, `confirm-button`, `controls`, `locate-button`, `hint`, `error-message`, `fallback`.
Field mode adds `field` (the compact control), `field-value` (the address, the coordinates or the placeholder), `field-button` (opens the sheet), `sheet` (the full-screen `<dialog>` holding the map), `sheet-header` (its title bar with Cancel and Confirm; Confirm is a `confirm-button`) and `cancel-button` (closes the sheet without changing the value).
**Slots:**
- `marker`: Custom pin graphic, anchored by its bottom-centre at the map centre.
- `controls`: Extra buttons placed with the built-in controls.
- `error`: Content shown when the map fails to load.
**Custom states (`:state(…)`):** `unset`, `loading`, `ready`, `error`, `dragging`, `locating`, `searching`, `interacted`, `open` (field mode: the sheet is open).
**CSS custom properties (`name=default`):**
- `--lp-pin-color=#e11d48`
- `--lp-pin-inner=#fff`
- `--lp-pin-size=44px`: height; width is size × 28/44
- `--lp-pin-lift=10px`
- `--lp-pin-shadow=rgb(0 0 0 / .35)`
- `--lp-unset-opacity=.6`
- `--lp-accent=light-dark(#1d4ed8, #93c5fd)`
- `--lp-surface=light-dark(#fff, #1f1f1f)`
- `--lp-on-surface=light-dark(#1f2937, #f3f4f6)`
- `--lp-radius=8px`
- `--lp-control-size=44px`
- `--lp-font=inherit`
- `--lp-tile-filter=none`
- `--lp-transition=160ms ease-out`
```html
<form method="post">
<label for="loc">Store location</label>
<location-picker id="loc" name="location" lat-name="lat" lng-name="lng" required search></location-picker>
</form>
```
**Members:**
- `formAssociated` (static, read-only): `true`.
- `observedAttributes` (static, read-only): `readonly string[]`.
- `version` (static, read-only): `string`. Example: `customElements.get('location-picker').version // "3.0.0"`
- `value`: get `string`, set `LatLngInput | null`. Live value: canonical `"lat,lng"` or `""` (unset). Setting is SILENT, marks the control dirty, accepts any LatLngInput or `""`/null. A string that does not parse warns (INVALID_VALUE) and clears; an object is normalised (latitude clamped with a warning). Never reflected. Default: `defaultValue`.
- `defaultValue`: `string`. Reflects the `value` attribute. Applies while not dirty. Form reset returns to it when it is non-empty (otherwise to the `lat`/`lng` attributes, then the bound-input seed defaults; see the class JSDoc). Default: `""`.
- `valueAsLatLng`: get `LatLng | null`, set `LatLngInput | null`. Live value as an object, or null. Setting is silent. Default: `null`.
- `lat`: get `number | null`, set `number | string | null`. Live latitude, or null while unset. SETTING writes the `lat` attribute, which sets the INITIAL value only (like `defaultValue`; latitude clamped with a warning): it applies while the control is not dirty. Once dirty (any pan, commit or `value` write, including the `el.value = ''` that Vue `v-model` and Angular `writeValue` do on mount), a changed `lat`/`lng` is ignored and logs one warning: "lat/lng only set the initial value; bind value="lat,lng" or call setLocation()". To control the position, bind `value`. Default: `null`.
- `lng`: get `number | null`, set `number | string | null`. Live longitude, or null while unset. SETTING writes the `lng` attribute: initial value only (see `lat`). Default: `null`.
- `name`: `string`. FormData entry `name=lat,lng` (the library appends it itself; the UA ignores host `name` for FormData values). Default: `""`.
- `latName`: `string`. Separate FormData entry for latitude. Default: `""`. Example: `el.latName = 'lat'`
- `lngName`: `string`. Separate FormData entry for longitude. Default: `""`.
- `addressName`: `string`. FormData entry for the address (`""` while unknown). Default: `""`.
- `valueInput`: `string`. Id or selector of an existing input that receives `"lat,lng"` (native events as for `latInput`). Default: `""`.
- `latInput`: `string`. Id or selector of an existing input that receives the latitude; also seeds the initial value. Native events: `input` live, `input` + `change` on commits, none for silent writes; non-bubbling when the input is inside the element (see `LocationPickerOptions.latInput`). Default: `""`. Example: `el.latInput = 'lat'`
- `lngInput`: `string`. Same as `latInput`, for longitude. Default: `""`.
- `addressInput`: `string`. Id or selector of an existing input that receives the address. Default: `""`.
- `form`: get `HTMLFormElement | null`, set `string | null`. The form owner (`internals.form`). Writes the `form` content attribute (a form id); `null` or `""` removes it. Lets React 19 render `form="f1"` without a TypeError.
- `labels` (read-only): `NodeListOf<HTMLLabelElement>`.
- `type` (read-only): `'location-picker'`. Always "location-picker".
- `required`: `boolean`. Default: `false`.
- `disabled`: `boolean`. Default: `false`.
- `readOnly`: `boolean`. View-only (zoom allowed); barred from constraint validation. Attribute `readonly`. Default: `false`.
- `validity` (read-only): `ValidityState`.
- `validationMessage` (read-only): `string`.
- `willValidate` (read-only): `boolean`.
- `checkValidity`: `(): boolean`.
- `reportValidity`: `(): boolean`. Focuses the map region and shows the validation bubble there.
- `setCustomValidity`: `(message: string): void`.
- `center`: get `string`, set `LatLngInput | null`. View centre while unset, as canonical "lat,lng" ("" = world view 20,0). Setting accepts any LatLngInput (strings parsed, objects normalised). Default: `""`.
- `zoom`: `number`. Live zoom; the attribute is the initial zoom. Default: 15 with value/center, else 2.
- `maxZoom`: `number`. Default: 19 for OSM tiles; engine maximum otherwise (NaN when unset).
- `precision`: `number`. Decimal places of the value (0–10). Default: `6`.
- `provider`: get `ProviderName | ProviderAdapter`, set `ProviderName | ProviderAdapter | string`. Provider name (reflected) or adapter object (property only). Changing it remounts and keeps the coordinates. When the resolved provider NAME changes (e.g. google -> leaflet), the address is cleared silently (`address`, the `address-name` entry, `address-input`), because Google content must not appear next to a non-Google map (ToS §3.2.3(e)) and vice versa; with `reverse-geocode` on, the new provider's paired geocoder looks it up again after `ready`. Default: 'leaflet' ('google' when api-key or map-id is set).
- `apiKey`: `string`. Default: `""`.
- `mapId`: `string`. Default: `""`.
- `tileUrl`: `string`. Default: `"https://tile.openstreetmap.org/{z}/{x}/{y}.png"`.
- `attribution`: `string`. Default: OSM credit.
- `mapStyle`: `string`. Default: `"https://tiles.openfreemap.org/styles/liberty"`.
- `engineUrl`: `string`. Engine JS URL; "none" = never auto-load; "" = default (pinned jsDelivr Leaflet). Default: `""`.
- `engineCssUrl`: `string`. Engine CSS URL; "none" = you provide it (copied from readable document CSS); "" = default order. Default: `""`.
- `engine`: `object | null`. Engine namespace (`L`, `maplibregl`, `google.maps`). Property only. Default: `null`.
- `mapOptions`: `Record<string, unknown>`. Passed to the engine map constructor. Property only. Changing it remounts. Default: `{}`.
- `search`: `boolean`. Default: `false`.
- `placeholder`: `string`. Default: `messages.searchPlaceholder`.
- `reverseGeocode`: `boolean`. Default: `false`.
- `geocoder`: `Geocoder | GeocodeFn | false | null`. Custom geocoder; false disables geocoding. Property only. Default: null (provider pair).
- `locateMode`: `LocateMode`. Locate-control mode; ATTRIBUTE `locate` (the one R3 exception, because `locate()` is the method). Reflects to `locate`. Default: `'off'`.
- `geolocation`: `PositionOptions`. Passed to navigator.geolocation by the locate button and `locate()`. Property only (object). Default: `{ enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 }`.
- `marker`: `MarkerMode`. Pin mode; a custom graphic goes in `slot="marker"`. Default: `'pin'`.
- `gestureHandling`: `GestureHandling`. Default: `'auto'`.
- `webmcp`: `boolean`. Register WebMCP tools (prefix from id, then name). For options use `registerWebMcpTools()`. Default: `false`.
- `presentation`: get `'inline' | 'field' | 'auto'`, set `'inline' | 'field' | 'auto' | null`. `inline`: the map in the page. `field`: a compact control (the address or coordinates, and a "Choose on map" button) that opens the map in a full-screen modal sheet; the value changes only on Confirm, and the engine loads on first open. `auto`: `field` while `(pointer: coarse) and (max-width: 640px)` matches, else `inline` (re-evaluated when it changes, never while the sheet is open). Form participation, names and events are the same in every mode. Reflects to `presentation`. Default: `'inline'`. Example: `<location-picker presentation="auto" name="location" search locate="button" required></location-picker>`
- `messages`: `Partial<Messages>`. UI text overrides. Property only. Default: `{}`.
- `address` (read-only): `string | null`. Address of the current value, or null.
- `map` (read-only): `unknown`. Engine map instance; undefined until ready.
- `ready` (read-only): `Promise<this>`. Resolves when the current mount is ready. Replaced SYNCHRONOUSLY by any setter that schedules a remount (and on reconnect after a teardown), so `el.provider = 'google'; await el.ready; el.map` sees the new map. The promise itself never triggers `unhandledrejection`; a promise derived from it (`await`, `.then()` without a rejection handler) does: add `.catch()`, or use the `ready`/`error` events.
- `getMarkerPosition`: `(): LatLng`. See `LocationPicker.getMarkerPosition` (warns once when called while unset; read `valueAsLatLng` instead).
- `setLocation`: `(lat: number, lng: number, options?: SetLocationOptions): void`. Commit (source "api"; fires input then change). Throws INVALID_VALUE for a string that does not parse; numbers/objects are normalised (see the class). Example: `el.setLocation(48.8584, 2.2945)`
- `setLocation`: `(position: LatLngInput, options?: SetLocationOptions): void`.
- `locate`: get `(options?: LocateOptions) => Promise<LocationDetail>`, set `LocateMode | '' | boolean | null`. Reading returns the locate METHOD (bound to this element; same function every time), so `el.locate()` works as on the class (@see LocationPicker.locate). WRITING sets the locate-control mode: `'off' | 'button' | 'auto' | ''` (`''` and `true` = `'button'`; `false`/`null` remove the `locate` attribute). It exists because React 19, Vue 3 and Preact assign `locate="button"` as a property (`'locate' in el` is true); without the setter they would overwrite the method with a string. Read the mode from `locateMode`. Example: `const { lat, lng } = await el.locate();`
- `setCurrentPosition`: `(options?: LocateOptions): Promise<LocationDetail>`. v1/v2 alias of `locate()` that keeps the current zoom (v2 only re-centred).
- `geocode`: `(query: string, options?: GeocodeOptions): Promise<GeocodeResult[]>`. See `LocationPicker.geocode`.
- `lookupAddress`: `(at?: LatLngInput, options?: GeocodeOptions): Promise<GeocodeResult | null>`. See `LocationPicker.lookupAddress`.
- `showPicker`: `(): void`. Field mode: opens the map sheet (a modal `<dialog>`; fires `toggle`). The map shows the value, or `center`, and nothing changes until Confirm. In inline mode it does nothing and warns once. Example: `el.showPicker()`
- `close`: `(): void`. Field mode: closes the sheet as Cancel does (the value stays as it was; fires `toggle`, no `input`/`change`).
- `addEventListener`: `<K extends keyof LocationPickerElementEventMap>(type: K, listener: (this: LocationPickerElement, ev: LocationPickerElementEventMap[K]) => unknown, options?: boolean | AddEventListenerOptions): void`.
- `addEventListener`: `(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void`.
- `removeEventListener`: `<K extends keyof LocationPickerElementEventMap>(type: K, listener: (this: LocationPickerElement, ev: LocationPickerElementEventMap[K]) => unknown, options?: boolean | EventListenerOptions): void`.
- `removeEventListener`: `(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void`.
### `defineLocationPicker`
Function: ``defineLocationPicker(tagName?: `${string}-${string}`): typeof LocationPickerElement | undefined``
Defines the element. Idempotent: returns the existing constructor when the tag is taken by ANY copy of this
library (warns once if versions differ). Returns undefined where `customElements` does not exist (server).
Other tag names register a trivial subclass. Importing `location-picker` or `location-picker/element` already
calls this with the default tag.
Default: tagName 'location-picker'. Example: `defineLocationPicker('geo-picker')`
### `LocationPickerAttributes`
Interface.
Attribute-spelled props for JSX/templates (kebab-case is SSR-correct; hyphen-less spellings are also accepted at
runtime). React 19, Vue 3 and Preact assign a non-hyphenated prop as a PROPERTY when `key in el`; every such key
here (`value`, `lat`, `lng`, `center`, `zoom`, `precision`, `name`, `form`, `required`, `disabled`, `provider`,
`attribution`, `search`, `placeholder`, `locate`, `marker`, `webmcp`, `presentation`) has a setter with the
attribute's meaning.
**Members:**
- `value`: `string` (optional).
- `lat`: `number | string` (optional).
- `lng`: `number | string` (optional).
- `center`: `string` (optional).
- `zoom`: `number | string` (optional).
- `max-zoom`: `number | string` (optional).
- `precision`: `number | string` (optional).
- `name`: `string` (optional).
- `lat-name`: `string` (optional).
- `lng-name`: `string` (optional).
- `address-name`: `string` (optional).
- `value-input`: `string` (optional).
- `lat-input`: `string` (optional).
- `lng-input`: `string` (optional).
- `address-input`: `string` (optional).
- `form`: `string` (optional).
- `required`: `boolean` (optional).
- `disabled`: `boolean` (optional).
- `readonly`: `boolean` (optional).
- `provider`: `ProviderName` (optional).
- `api-key`: `string` (optional).
- `map-id`: `string` (optional).
- `tile-url`: `string` (optional).
- `attribution`: `string` (optional).
- `map-style`: `string` (optional).
- `engine-url`: `string` (optional).
- `engine-css-url`: `string` (optional).
- `search`: `boolean` (optional).
- `placeholder`: `string` (optional).
- `reverse-geocode`: `boolean` (optional).
- `locate`: `LocateMode | ''` (optional).
- `marker`: `MarkerMode` (optional).
- `gesture-handling`: `GestureHandling` (optional).
- `webmcp`: `boolean` (optional).
- `presentation`: `'inline' | 'field' | 'auto'` (optional).
### `HTMLElementTagNameMap`
Global augmentation (`declare global`).
**Members:**
- `location-picker`: `LocationPickerElement`.
## `location-picker/core`
- `export * from 'location-picker/utils'`
- `export default LocationPicker`
### `version`
Constant: `version: string`
Library version.
Example: `version // "3.0.0"`
### `ProviderName`
Type: `type ProviderName = 'leaflet' | 'google' | 'maplibre'`
Built-in map providers. Aliases `"osm"`/`"openstreetmap"` -> `leaflet`, `"googlemaps"`/`"google-maps"` -> `google` are accepted with a warning.
### `ChangeSource`
Type: `type ChangeSource = 'user' | 'api' | 'geolocation' | 'search'`
What caused a committed change. Method calls (`setLocation()`) and WebMCP report `"api"`.
### `LocateMode`
Type: `type LocateMode = 'off' | 'button' | 'auto'`
Locate-me control. `"button"`: a button that prompts only on click (hidden without a secure context). `"auto"`: also try once on load when no value is set (PROMPTS for permission).
### `MarkerMode`
Type: `type MarkerMode = 'pin' | 'none' | 'advanced'`
Centre marker. `"none"` hides it (issue #9). `"advanced"` = Google AdvancedMarkerElement (Google only; needs `mapId`).
### `GestureHandling`
Type: `type GestureHandling = 'auto' | 'cooperative' | 'greedy' | 'none'`
Gesture policy. `"auto"`: Google's own auto; on Leaflet/MapLibre it is `"cooperative"` (Ctrl/⌘ + wheel to zoom,
with a hint) when the page itself scrolls at mount time, otherwise `"greedy"`. `"none"`: no gestures (buttons/keys only).
### `InputRef`
Type: `type InputRef = string | HTMLInputElement | HTMLTextAreaElement`
An input to write into: an element, an id (tried first) or a CSS selector, resolved in the picker's root node.
### `Bounds`
Interface.
Axis-aligned bounds, lat-first. `west > east` means the box crosses the antimeridian.
**Members:**
- `south`: `number`.
- `west`: `number`.
- `north`: `number`.
- `east`: `number`.
### `LocationDetail`
Interface (`extends LatLng`).
Detail of `input` and `change`. `lat`, `lng` and `value` are always consistent:
`parseLatLng(value)` deep-equals `{ lat, lng }` (all rounded to `precision`).
Example: `picker.addEventListener('change', (e) => save(e.detail.lat, e.detail.lng, e.detail.address));`
**Members:**
- `value`: `string`. Canonical form value. Example: `"48.85837,2.294481"`
- `source`: `ChangeSource`. What caused the change. Example: `"user"`
- `zoom`: `number`. Zoom at the time of the event. Example: `15`
- `address`: `string | null`. Address when known at commit time (search pick, cached reverse result); otherwise null. See `addresschange`. Default: `null`.
- `accuracy`: `number` (optional). Metres; present only for `source === "geolocation"`. Default: `undefined`.
- `place`: `GeocodeResult` (optional). The search result that produced this value; present only for `source === "search"`. Default: `undefined`.
### `AddressDetail`
Interface (`extends LatLng`).
Detail of `addresschange`: the address for the CURRENT value resolved (stale results are dropped).
**Members:**
- `value`: `string`. The value the address belongs to. Example: `"48.85837,2.294481"`
- `address`: `string | null`. null when the lookup failed or found nothing. Example: `"Champ de Mars, 75007 Paris, France"`
- `result`: `GeocodeResult | null`. The raw result, or null.
### `ReadyDetail`
Interface.
Detail of `ready`.
**Members:**
- `provider`: `string`. Resolved provider name. Example: `"leaflet"`
- `map`: `TMap`. The engine map instance (L.Map, google.maps.Map, maplibregl.Map).
### `LocationPickerEventMap`
Interface.
Events dispatched by `LocationPicker` (on the instance) and `<location-picker>` (on the element).
| event | bubbles | composed | cancelable | fires |
| ------------- | ------- | -------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| input | yes | yes | no | live during a user gesture (at most once per animation frame, source "user"); once with the final value right before EVERY `change`; and at the end of a gesture whenever the final value differs from the last emitted `input`, even when `change` is de-duplicated away |
| change | yes | no | no | committed: end of a user pan/keyboard move, Enter/confirm, search pick, geolocation, and METHOD calls (`setLocation`, `locate`, WebMCP). Never for property writes, form reset or restore. De-duplicated on the rounded value. |
| addresschange | yes | yes | no | address for the current value resolved (reverse geocode or search pick) |
| ready | no | no | no | engine map mounted (again after each remount); always async |
| error | no | no | YES | async failure; if no listener calls `preventDefault()` (and no `onError`), it is logged with its fix |
**Members:**
- `input`: `CustomEvent<LocationDetail>`.
- `change`: `CustomEvent<LocationDetail>`.
- `addresschange`: `CustomEvent<AddressDetail>`.
- `ready`: `CustomEvent<ReadyDetail<TMap>>`.
- `error`: `CustomEvent<LocationPickerError>`.
### `GeocodeResult`
Interface (`extends LatLng`).
One geocoder result.
Example: `{ lat: 48.8584, lng: 2.2945, label: 'Eiffel Tower', address: 'Avenue Anatole France, 75007 Paris, France' }`
**Members:**
- `label`: `string`. Display text for the result list. Example: `"Eiffel Tower"`
- `address`: `string` (optional). Postal-style one-line address when known. Default: `undefined`.
- `bounds`: `Bounds` (optional). Extent to fit when picked (areas); the pin still lands on `lat`/`lng`. Default: `undefined`.
- `id`: `string` (optional). Provider id (Photon osm_id, Google place id). Default: `undefined`.
- `raw`: `unknown` (optional). Untouched provider payload (Photon GeoJSON Feature, google.maps.places.Place, google.maps.GeocoderResult).
### `GeocodeSearchOptions`
Interface.
Context passed to `Geocoder.search`.
**Members:**
- `signal`: `AbortSignal`. Aborted when a newer query starts or the picker is destroyed.
- `near`: `LatLng`. Current map centre, for result bias.
- `limit`: `number`. Maximum results wanted. Default: `5`.
- `lang`: `string`. BCP 47 language tag. Example: `"fr"`
### `GeocodeReverseOptions`
Interface.
Context passed to `Geocoder.reverse`.
**Members:**
- `signal`: `AbortSignal`.
- `lang`: `string`.
- `zoom`: `number`. Current zoom (lets a geocoder pick street vs city granularity).
### `Geocoder`
Interface.
A geocoder. Implement either method or both; a missing method disables that feature.
Pairing rule (Google Maps Platform ToS §3.2.3(e)): a geocoder whose `pairsWith` excludes the active provider is
refused with `GEOCODER_MISMATCH` and search is disabled. A custom geocoder without `pairsWith` works with every
provider; YOU are responsible for its terms (never show non-Google results on a Google map).
Example: ``const geocoder: Geocoder = { search: async (q, { signal }) => (await fetch(`/geo?q=${encodeURIComponent(q)}`, { signal })).json() };``
**Members:**
- `pairsWith` (read-only): `readonly string[]` (optional). Providers whose maps may display these results. Default: undefined (any). Example: `['leaflet', 'maplibre']`
- `search`: `(query: string, options: GeocodeSearchOptions): Promise<GeocodeResult[]>`. Forward geocoding for the search box and `geocode()`.
- `reverse`: `(at: LatLng, options: GeocodeReverseOptions): Promise<GeocodeResult | null>`. Reverse geocoding for `reverseGeocode` and `lookupAddress()`.
- `attribution` (read-only): `string` (optional). Extra attribution HTML appended to the map attribution while this geocoder is active. Default: `undefined`.
### `GeocodeFn`
Type: `type GeocodeFn = (query: string, options: GeocodeSearchOptions) => Promise<GeocodeResult[]>`
Shorthand: a bare function acts as `Geocoder.search`.
Example: `geocoder: (q, { signal }) => myApi.search(q, signal)`
### `Messages`
Interface.
UI strings. English is built in; override any subset with `messages`. Form values are never localised.
**Members:**
- `label`: `string`. Accessible name when no `<label>`/`aria-label` exists. Default: `"Location picker"`.
- `roleDescription`: `string`. Default: `"map"`.
- `instructions`: `string`. Screen-reader help for the map region. Default: `"Drag the map or use the arrow keys to move the pin; plus and minus zoom. Press Enter to choose the point under the pin."`.
- `confirm`: `string`. Confirm button shown while unset when a `center` was given; in field mode, the sheet's Confirm button. Default: `"Use this location"`.
- `locate`: `string`. Locate button label. Default: `"Use my location"`.
- `locating`: `string`. Default: `"Finding your location…"`.
- `searchLabel`: `string`. Accessible label of the search box. Default: `"Search for a place or paste coordinates"`.
- `searchPlaceholder`: `string`. Default: `"Search or paste coordinates"`.
- `noResults`: `string`. Default: `"No results"`.
- `goTo`: `(coords: string) => string`. Offline option shown when the query parses as coordinates. Default: (coords) => `Go to ${coords}`.
- `announce`: `(detail: LocationDetail) => string`. Live-region text after a commit (polite, 700 ms debounce; never during panning). Default: (d) => `Location set: ${d.lat.toFixed(5)}, ${d.lng.toFixed(5)}${d.address ? '. ' + d.address : ''}`.
- `valueMissing`: `string`. Validation message for `required`. Default: `"Please choose a location on the map."`.
- `geolocationDenied`: `string`. Default: `"Location permission was denied."`.
- `geolocationUnavailable`: `string`. Default: `"Your location is unavailable."`.
- `geolocationTimeout`: `string`. Default: `"Finding your location took too long."`.
- `geolocationUnsupported`: `string`. Default: `"Location needs HTTPS and a browser that supports it."`.
- `cooperativeHint`: `string`. Cooperative-gesture hint. Default: "Use Ctrl + scroll to zoom the map" ("⌘ + scroll" on Apple platforms).
- `loadError`: `string`. Shown when the engine fails to load. Default: `"The map could not be loaded. Type coordinates instead."`.
- `fallbackLabel`: `string`. Label of the fallback coordinate input shown after a load failure. Default: `"Latitude, longitude"`.
- `chooseOnMap`: `string`. Field mode: the button that opens the map sheet. Default: `"Choose on map"`.
- `fieldPlaceholder`: `string`. Field mode: the field text while no location is chosen. Default: `"No location selected"`.
- `cancel`: `string`. Field mode: the sheet's Cancel button. Default: `"Cancel"`.
### `LocationPickerErrorCode`
Type: `type LocationPickerErrorCode = | 'TARGET_NOT_FOUND' | 'INVALID_VALUE' | 'INVALID_OPTION' | 'ENGINE_MISSING' | 'ENGINE_LOAD_FAILED' | 'ENGINE_CSS_MISSING' | 'API_KEY_MISSING' | 'GOOGLE_AUTH_FAILED' | 'MAP_ID_REQUIRED' | 'GEOCODER_MISMATCH' | 'GEOCODER_MISSING' | 'GEOCODER_FAILED' | 'GEOLOCATION_UNSUPPORTED' | 'GEOLOCATION_DENIED' | 'GEOLOCATION_UNAVAILABLE' | 'GEOLOCATION_TIMEOUT' | 'ABORTED'`
Stable error codes. `level` (see `LocationPickerError.level`) decides how an unhandled `error` event is logged.
Default level per code (the `ERROR_TABLE`); the only per-case override is listed in the last column:
| level | codes | per-case override |
| -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| `error` (console.error) | TARGET_NOT_FOUND, INVALID_OPTION, ENGINE_MISSING, ENGINE_LOAD_FAILED, API_KEY_MISSING, GOOGLE_AUTH_FAILED, GEOCODER_MISMATCH, GEOCODER_MISSING | INVALID_OPTION for "only one of `lat`/`lng` given" is `warn` |
| `warn` (console.warn) | INVALID_VALUE, ENGINE_CSS_MISSING, MAP_ID_REQUIRED, GEOCODER_FAILED, GEOLOCATION_UNSUPPORTED, GEOLOCATION_DENIED, GEOLOCATION_UNAVAILABLE, GEOLOCATION_TIMEOUT | – |
| `silent` (never logged) | ABORTED | – |
| Unknown provider names are INVALID_OPTION at `error`. Console diagnostics (unknown keys, wrong-guess attributes, |
| name/bound-input collisions, a 0-height target, a clamped latitude) are separate `console.warn` lines, never events. |
### `LocationPickerError`
Class (`extends Error`).
Every thrown error, every promise rejection and every `error` event detail.
`message` format: `"[location-picker] <what happened>. Fix: <fix>"`.
Geolocation codes come from the numeric `GeolocationPositionError.code` (which is NOT an `Error` instance; it is
kept as `cause`).
```ts
try { await picker.locate(); } catch (e) {
if (e instanceof LocationPickerError && e.code === 'GEOLOCATION_DENIED') showHelp(e.fix);
}
```
**Members:**
- `constructor`: `(code: LocationPickerErrorCode, message?: string, options?: { fix?: string; cause?: unknown })`. Parameter `code`: stable code. Parameter `message`: human text; the built-in text for `code` when omitted. Parameter `options`: `fix` (one-line remedy; built-in when omitted) and `cause`.
- `name` (read-only): `'LocationPickerError'`.
- `code` (read-only): `LocationPickerErrorCode`. Stable, documented code. Example: `"GEOLOCATION_DENIED"`
- `fix` (read-only): `string`. One-line, machine-readable remedy for humans and agents. Example: `'Add api-key="YOUR_KEY", or load the Google Maps JS API before the picker.'`
- `level` (read-only): `'error' | 'warn' | 'silent'`. How an unhandled `error` event with this error is logged.
- `cause` (read-only): `unknown` (optional). Underlying failure (GeolocationPositionError, script `error` Event, fetch error, AbortSignal reason).
### `SetLocationOptions`
Interface.
Options for `LocationPicker.setLocation`.
**Members:**
- `zoom`: `number` (optional). Also set the zoom. Default: current zoom.
- `animate`: `boolean` (optional). Animate the move. Ignored under prefers-reduced-motion. Default: `false`.
### `LocateOptions`
Interface (`extends PositionOptions`).
Options for `LocationPicker.locate`. Defaults merge over the picker's `geolocation` option.
**Members:**
- `signal`: `AbortSignal` (optional). Aborting rejects with `ABORTED` and leaves the value unchanged.
### `GeocodeOptions`
Interface.
Options for `LocationPicker.geocode` and `LocationPicker.lookupAddress`.
**Members:**
- `signal`: `AbortSignal` (optional).
- `limit`: `number` (optional). Default: `5`.
### `LocationPickerOptions`
Interface.
Options for `new LocationPicker(target, options)`. Every key also exists on `<location-picker>` as a property and
(for primitives) a kebab-case attribute, except: callbacks, `injectStyles`, `signal`, deprecated keys.
Unknown keys log a warning that lists the valid keys.
**Members:**
- `provider`: `ProviderName | ProviderAdapter<TMap>` (optional). Map provider: a built-in name (its adapter is lazy-loaded) or an adapter object from `location-picker/leaflet` | `/google` | `/maplibre` (statically imported; enables synchronous `.map`). Resolution when omitted: `apiKey`, `mapId`, `marker: 'advanced'`, `useAdvancedMarker` or a 3rd constructor argument select `'google'`; otherwise `'leaflet'`. A loaded `google.maps` global is NEVER sniffed (it only triggers a one-time console.info hint). Default: `'leaflet'`. Example: `{ provider: 'google', apiKey: 'AIza…' }`
- `value`: `LatLngInput | null` (optional). Initial committed value. `null` = unset (form value ""). Lenient: any `LatLngInput`. A string that does not parse logs `INVALID_VALUE` and leaves the value unset; an object is normalised (latitude clamped with a warning). Default: `null`. Example: `{ value: '48.8584,2.2945' }`
- `center`: `LatLngInput` (optional). Where the view starts while the value is unset. Never becomes the value by itself. Default: { lat: 20, lng: 0 } (world view). Example: `{ center: '33.8938,35.5018' }`
- `zoom`: `number` (optional). Initial zoom. Default: 15 when `value` or `center` is given, else 2. Example: `{ zoom: 12 }`
- `maxZoom`: `number` (optional). Maximum zoom. Default: 19 (OSM tiles); the engine's own maximum for Google/MapLibre.
- `precision`: `number` (optional). Decimal places of the committed value (6 is about 0.11 m). Default: `6`. Example: `{ precision: 5 }`
- `disabled`: `boolean` (optional). No interaction at all; controls disabled. Default: `false`.
- `readOnly`: `boolean` (optional). View-only: zoom allowed, the value cannot change (no pan, search or locate). Default: `false`.
- `marker`: `MarkerMode | HTMLElement` (optional). Centre marker; an element replaces the SVG (anchored by its bottom-centre). Default: `'pin'`. Example: `{ marker: 'none' }`
- `gestureHandling`: `GestureHandling` (optional). Default: `'auto'`. Example: `{ gestureHandling: 'greedy' }`
- `locate`: `LocateMode | boolean` (optional). Locate-me control. `true` = `"button"`, `false` = `"off"`. Never prompts on load unless `"auto"`. Default: `'off'`. Example: `{ locate: 'button' }`
- `geolocation`: `PositionOptions` (optional). Passed to navigator.geolocation. A watchdog also rejects with GEOLOCATION_TIMEOUT after `timeout + 30000` ms (covers an unanswered permission prompt). Default: `{ enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 }`.
- `search`: `boolean` (optional). Search box (Photon for Leaflet/MapLibre, Google PlaceAutocompleteElement for Google). Typed or pasted coordinates jump directly with no network call. Default: `false`. Example: `{ search: true }`
- `placeholder`: `string` (optional). Search box placeholder. Default: `messages.searchPlaceholder`.
- `reverseGeocode`: `boolean` (optional). Reverse-geocode each committed value (debounced 400 ms, cached), firing `addresschange`. Default: `false`.
- `geocoder`: `Geocoder | GeocodeFn | false` (optional). Replace the provider's paired geocoder; `false` disables geocoding entirely. Default: the provider's pair (Photon / Google). Example: `{ geocoder: photonGeocoder({ url: 'https://photon.example.org/api' }) }`
- `latInput`: `InputRef` (optional). Existing input that receives the rounded latitude and seeds the initial value. It mirrors the picker's events: native `input` for live changes, `input` + `change` on commits, nothing for silent writes (property writes, reset, restore). Its events are dispatched BEFORE the picker's own `input`/`change`. They bubble, except on an input inside a `<location-picker>` ELEMENT (progressive enhancement), where they are non-bubbling so the host only ever sees its own CustomEvents (class mode always bubbles: its events go to the instance, not the DOM). Listeners on the input itself (Livewire `wire:model`, Alpine `x-model`, jQuery) still fire. Example: `{ latInput: '#lat' }`
- `lngInput`: `InputRef` (optional). Same as `latInput`, for longitude. Example: `{ lngInput: '#lng' }`
- `valueInput`: `InputRef` (optional). Receives the canonical `"lat,lng"` string. Example: `{ valueInput: '#location' }`
- `addressInput`: `InputRef` (optional). Receives the address ("" while unknown). Needs `search` or `reverseGeocode`. Example: `{ addressInput: '#address' }`
- `messages`: `Partial<Messages>` (optional). UI text overrides. Default: `English`. Example: `{ messages: { locate: 'Ma position' } }`
- `lang`: `string` (optional). BCP 47 language for UI, Photon `lang` (when supported) and Google `language`. Default: closest [lang], then navigator.language.
- `injectStyles`: `boolean` (optional). Adopt the picker CSS into the target's root node (adoptedStyleSheets; `<style nonce>` fallback). `false` + `import 'location-picker/style.css'` for strict CSP. Default: `true`.
- `nonce`: `string` (optional). CSP nonce for any `<script>`, `<link>` or `<style>` the library inserts. Default: nonce of the document's first `script[nonce]`.
- `webmcp`: `boolean` (optional). Register WebMCP tools (experimental; no-op when unsupported). Lazy-loads `location-picker/webmcp`. Default: `false`. **3.0:** on the class this option does not register tools yet (it arrives in 3.1); call `registerWebMcpTools(picker)` instead. The element's `webmcp` attribute works.
- `signal`: `AbortSignal` (optional). Aborting it calls `destroy()`.
- `apiKey`: `string` (optional). Google browser key (restrict by HTTP referrer). Injects Google's official inline bootstrap if google.maps is absent. Selects `provider: 'google'` when `provider` is omitted.
- `mapId`: `string` (optional). Google map ID; needed only for `marker: 'advanced'`. Selects `'google'` when `provider` is omitted.
- `tileUrl`: `string` (optional). Leaflet raster tile URL template, or (MapLibre) an XYZ template wrapped into a raster style. Use your own/commercial tiles in production. Default: `'https://tile.openstreetmap.org/{z}/{x}/{y}.png'`.
- `attribution`: `string` (optional). Attribution HTML, always visible. Forced to the OSM credit while `tileUrl` is the default; a custom `tileUrl` without it logs a warning. Default: `'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'`.
- `mapStyle`: `string | object` (optional). MapLibre style URL or object. Default: `'https://tiles.openfreemap.org/styles/liberty'`.
- `engine`: `object` (optional). Engine namespace you already have (`L` from 'leaflet', `maplibregl`, `google.maps`). Default: globalThis.L / globalThis.maplibregl / globalThis.google.maps.
- `engineUrl`: `string | false` (optional). Engine JS to auto-load when the engine is absent; `false` = never load (ENGINE_MISSING instead). SRI is applied only to the pinned default. MapLibre: an ESM URL loaded with import(), opt-in, no default. Ignored by Google. Default: pinned jsDelivr leaflet@1.9.4 (Leaflet); none (MapLibre).
- `engineCssUrl`: `string | false` (optional). Engine CSS URL; `false` = you provide it (document CSS is copied into shadow roots when readable). MapLibre never fetches CSS unless this is a URL. Default: see CONTRACT.md §4.3 resolution order.
- `mapOptions`: `Record<string, unknown>` (optional). Passed to the engine's map constructor; wins over library defaults. Default: `{}`. Example: `{ mapOptions: { mapTypeId: 'hybrid' } }`
- `onInput`: `(detail: LocationDetail) => void` (optional). Same as `addEventListener('input')`, receives `detail`.
- `onChange`: `(detail: LocationDetail) => void` (optional). Same as `addEventListener('change')`, receives `detail`. Example: `{ onChange: ({ lat, lng, source }) => save(lat, lng) }`
- `onAddressChange`: `(detail: AddressDetail) => void` (optional). Same as `addEventListener('addresschange')`.
- `onReady`: `(detail: ReadyDetail<TMap>) => void` (optional). Same as `addEventListener('ready')`.
- `onError`: `(error: LocationPickerError) => void` (optional). Same as `addEventListener('error')`. Its presence marks errors as handled (they are not logged).
- `onLocationChange`: `(detail: LocationDetail) => void` (optional). **Deprecated.** v2. Called like `onChange` (de-duplicated; the detail is a superset of LatLng). Like v2, it is also
called ONCE after `ready` with the current value (source `"api"`), but only when a value is set: an unset picker
has no position to report.
- `lat`: `number` (optional). **Deprecated.** v1/v2. Use `value`. Used only when both `lat` and `lng` are finite; 0 is valid; latitude is clamped into [-90, 90] with a warning. NOT a provider signal.
- `lng`: `number` (optional). **Deprecated.** v1/v2. Use `value`.
- `setCurrentPosition`: `boolean` (optional). **Deprecated.** v1/v2. `true` -> `locate: 'auto'`. The v1 default was true; v3 default is `'off'`.
- `useAdvancedMarker`: `boolean` (optional). **Deprecated.** v2. -> `marker: 'advanced'` (Google only; needs `mapId`). Selects `'google'` when `provider` is omitted.
### `LocationPicker`
Class (`extends EventTarget`).
Imperative picker. Renders into a light-DOM container YOU own: `target` keeps its children; the picker appends one
`div.lp-base` wrapper holding `.lp-map`, `.lp-pin.centerMarker` (v1 alias), `.lp-controls`, `.lp-status` (plus
`.lp-search` when `search` is on). State classes (`lp-is-unset`, `lp-is-dragging`, …) live on `.lp-base`, never on
`target`, so a framework that rewrites `target.className` breaks nothing. The picker also adds the class
`location-picker` to `target` (only for v1 CSS overrides such as `.location-picker .centerMarker{display:none}`)
and, when `target` computes `position: static`, an inline `position: relative`. Re-constructing on the same target
destroys the previous instance.
Not a form control: use `<location-picker>` for forms, or bind inputs with `latInput`/`lngInput`.
```ts
const picker = new LocationPicker('#map', { search: true, onChange: (d) => console.log(d.value) });
await picker.ready;
const chosen = picker.valueAsLatLng; // null until the user chose a point
```
**Members:**
- `constructor`: `(target: string | HTMLElement, options?: LocationPickerOptions<TMap>)`. Parameter `target`: an element, an id (`"map"`, tried first) or a CSS selector (`"#map"`). Throws `TARGET_NOT_FOUND`,
or `INVALID_OPTION` if `target` is a `<location-picker>` (use the element directly).. Parameter `options`: see `LocationPickerOptions`.
- `constructor`: `(target: string | HTMLElement, options: LocationPickerOptions<TMap> | undefined, mapOptions: Record<string, unknown>)`. **Deprecated.** v1/v2 signature. The 3rd argument is merged into `options.mapOptions` and selects
`provider: 'google'` unless `options.provider` is set (logs one deprecation warning).
- `version` (static, read-only): `string`. Example: `LocationPicker.version // "3.0.0"`
- `element` (read-only): `HTMLElement`. The target element (v1/v2 `.element`).
- `container` (read-only): `HTMLElement`. The map container div the picker created inside `element`.
- `provider` (read-only): `string`. Resolved provider name. Example: `"leaflet"`
- `map` (read-only): `TMap | undefined`. Engine map (L.Map, google.maps.Map, maplibregl.Map). `undefined` until `ready`, except when an adapter OBJECT is passed and its engine is already present: then it is set synchronously in the constructor.
- `ready` (read-only): `Promise<this>`. Resolves when the map is mounted and interactive; rejects with LocationPickerError. The promise itself never triggers `unhandledrejection` when ignored. A promise you DERIVE from it (`await`, or `.then()` without a rejection handler) rejects like any other promise: add `.catch()`, or use the `ready`/`error` events. Example: `picker.ready.then((p) => p.map).catch((e) => console.warn(e.code));`
- `value`: get `string`, set `LatLngInput | null`. Committed canonical `"lat,lng"`, or `""` while unset. Setting is SILENT (no events) and accepts any LatLngInput or `""`/null to clear. A string that does not parse warns (INVALID_VALUE) and clears; an object is normalised (latitude clamped with a warning). Default: `""`.
- `valueAsLatLng`: get `LatLng | null`, set `LatLngInput | null`. Committed value as an object, or null while unset. Setting is silent. Default: `null`.
- `address` (read-only): `string | null`. Address of the current value (search pick or reverse geocode), or null.
- `zoom`: `number`. Live zoom. Setting zooms around the pin and never changes the value.
- `disabled`: `boolean`. Default: `false`.
- `readOnly`: `boolean`. Default: `false`.
- `locateMode`: `LocateMode`. Locate-control mode (option `locate`). Default: `'off'`.
- `getMarkerPosition`: `(): LatLng`. v1/v2: the current map centre (the point under the pin), normalised, unrounded. Never null: before the map exists it returns the value, else the centre. While the picker is UNSET this is only the view centre (e.g. the 20,0 world view), not a chosen location, and the first such call logs one console warning. To read the user's choice, use `valueAsLatLng` (null while unset) or `value` (`""` while unset). Example: `const { lat, lng } = picker.getMarkerPosition();`
- `setLocation`: `(lat: number, lng: number, options?: SetLocationOptions): void`. Move the pin and COMMIT (fires `input` then `change` with source `"api"` when the rounded value changed). Throws `INVALID_VALUE` for a string that does not parse. Numbers and objects are normalised: `setLocation(95, 10)` commits `"90,10"` and logs one warning ("latitude 95 was clamped to 90; did you swap lat and lng? try (10, 95)"). Example: `picker.setLocation(48.8584, 2.2945)`
- `setLocation`: `(position: LatLngInput, options?: SetLocationOptions): void`. Example: `picker.setLocation('48.8584, 2.2945', { zoom: 17 })`
- `locate`: `(options?: LocateOptions): Promise<LocationDetail>`. Geolocate and commit (source `"geolocation"`; zoom becomes max(current, 17)). Rejects with GEOLOCATION_UNSUPPORTED | GEOLOCATION_DENIED | GEOLOCATION_UNAVAILABLE | GEOLOCATION_TIMEOUT | ABORTED; never hangs. Does not fire `error` (the promise is the channel). Example: `const { lat, lng } = await picker.locate();`
- `setCurrentPosition`: `(options?: LocateOptions): Promise<LocationDetail>`. v1/v2 alias of `LocationPicker.locate` that keeps the current zoom (v2 only re-centred). Same promise, errors and `change` (source `"geolocation"`).
- `geocode`: `(query: string, options?: GeocodeOptions): Promise<GeocodeResult[]>`. Forward-geocode with the paired (or custom) geocoder; results are NOT committed. A query that parses as coordinates returns one offline result. Rejects GEOCODER_MISSING / GEOCODER_FAILED / ABORTED. Example: `const [best] = await picker.geocode('Eiffel Tower');`
- `lookupAddress`: `(at?: LatLngInput, options?: GeocodeOptions): Promise<GeocodeResult | null>`. Reverse-geocode `at` (default: the current value); cached. When `at` is the current value, also updates `address` and fires `addresschange`. Example: `const r = await picker.lookupAddress(); console.log(r?.address);`
- `destroy`: `(): void`. Idempotent. Removes the DOM and listeners the picker created, removes the map it created, aborts pending work (pending promises reject with ABORTED).
- `addEventListener`: `<K extends keyof LocationPickerEventMap<TMap>>(type: K, listener: (this: this, ev: LocationPickerEventMap<TMap>[K]) => unknown, options?: boolean | AddEventListenerOptions): void`.
- `addEventListener`: `(type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions): void`.
- `removeEventListener`: `<K extends keyof LocationPickerEventMap<TMap>>(type: K, listener: (this: this, ev: LocationPickerEventMap<TMap>[K]) => unknown, options?: boolean | EventListenerOptions): void`.
- `removeEventListener`: `(type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | EventListenerOptions): void`.
### `MapEvents`
Interface.
Engine -> core notifications. Adapters MUST call these from engine events; the core does rounding, normalisation, de-duplication and source attribution.
**Members:**
- `move`: `(center: LatLng): void`. Centre moved for any reason (Leaflet/MapLibre `move`, Google `center_changed`). Raw centre is fine.
- `moveEnd`: `(center: LatLng, zoom: number): void`. Camera settled (Leaflet/MapLibre `moveend`, Google `idle`).
- `interactionStart`: `(): void`. A user gesture began (pointerdown, wheel, key). Marks the next commit as source "user".
- `interactionEnd`: `(): void`. The gesture ended (drives `:state(dragging)` and the pin lift).
### `ProviderMountOptions`
Interface.
Resolved provider-relevant options handed to adapters (factory options win over these).
**Members:**
- `apiKey` (read-only): `string` (optional).
- `mapId` (read-only): `string` (optional).
- `tileUrl` (read-only): `string` (optional).
- `attribution` (read-only): `string` (optional).
- `mapStyle` (read-only): `string | object` (optional).
- `engine` (read-only): `object` (optional).
- `engineUrl` (read-only): `string | false` (optional).
- `engineCssUrl` (read-only): `string | false` (optional).
- `mapOptions` (read-only): `Readonly<Record<string, unknown>>`.
- `maxZoom` (read-only): `number` (optional).
- `marker` (read-only): `MarkerMode | 'element'`.
- `search` (read-only): `boolean`.
### `MountContext`
Interface.
Everything an adapter may use. Adapters never write outside `container` (LOCKED 5).
**Members:**
- `container` (read-only): `HTMLElement`. Empty div owned by location-picker and sized by the host. Create the map inside it; never touch its siblings.
- `root` (read-only): `Document | ShadowRoot`. Where engine CSS must go: the Document (class) or the element's ShadowRoot.
- `view` (read-only): `{ readonly center: LatLng; readonly zoom: number }`. Initial camera.
- `gestureHandling` (read-only): `GestureHandling`. As configured. Leaflet/MapLibre resolve `"auto"` with `pageScrolls`; Google passes it through natively.
- `pageScrolls` (read-only): `boolean`. True when the document itself scrolls at mount time (`auto` then means cooperative on Leaflet/MapLibre).
- `interactive` (read-only): `'full' | 'zoom-only' | 'none'`. `"full"` pan+zoom, `"zoom-only"` (readOnly), `"none"` (disabled).
- `lang` (read-only): `string`. BCP 47.
- `colorScheme` (read-only): `'light' | 'dark' | 'auto'`. From the host's computed CSS `color-scheme`.
- `options` (read-only): `ProviderMountOptions`.
- `signal` (read-only): `AbortSignal`. Aborted on destroy or remount.
- `events` (read-only): `MapEvents`.
- `nonce` (read-only): `string | undefined`. CSP nonce to put on inserted elements, if any.
- `loadScript`: `(src: string, integrity?: string): Promise<void>`. Inserts a classic `<script>` once per URL per document (nonce, crossorigin="anonymous", optional SRI); resolves on load, rejects with ENGINE_LOAD_FAILED.
- `loadStylesheet`: `(href: string, integrity?: string): Promise<void>`. Adds `<link rel=stylesheet>` to `root` once per URL, with the nonce, and with `crossorigin="anonymous"` whenever `integrity` is given (SRI requires CORS; without it the browser blocks the sheet). A custom URL without `integrity` gets no `crossorigin`, so CDNs that send no CORS headers still work. Never rejects: resolves on `load`, on `error` or after 3000 ms; the caller then verifies the engine CSS (ENGINE_CSS_MISSING). Mount never waits for it.
- `adoptDocumentRules`: `(selectorTest: RegExp): Promise<number>`. Copies rules whose selectors match `selectorTest` from readable document stylesheets into a constructed sheet adopted by `root` (no-op for a Document root). Resolves to the number of rules copied (0 if none/unreadable).
- `showHint`: `(text: string): void`. Shows a transient hint over the map (cooperative gestures).
- `report`: `(error: LocationPickerError): void`. Reports a non-fatal or post-mount failure (e.g. Google auth failure) through the `error` event.
### `MapHandle`
Interface.
Live handle to a mounted map.
**Members:**
- `map` (read-only): `TMap`.
- `getCenter`: `(): LatLng`. Raw centre; the core normalises.
- `getZoom`: `(): number`.
- `setView`: `(center: LatLng, zoom: number | undefined, options: { animate: boolean }): void`. Must not report a user interaction.
- `setZoom`: `(zoom: number): void`. Zoom anchored at the centre (never moves the centre).
- `setInteractive`: `(mode: 'full' | 'zoom-only' | 'none'): void`.
- `resize`: `(): void`. Called by the core's ResizeObserver (also after initialising while hidden).
- `drawsMarker` (read-only): `boolean` (optional). True when the adapter draws the marker itself (Google AdvancedMarkerElement); the core then hides its CSS pin. Default: `false`.
- `a11y` (read-only): `'core' | 'engine'` (optional). `"core"`: the core labels the container `role=application`. `"engine"`: the engine renders its own accessible region (Google). Default: `'core'`.
- `destroy`: `(): void`. Release everything the adapter created (listeners, map).
### `SearchContext`
Interface (`extends MountContext`).
Context for a provider-rendered search UI (Google).
**Members:**
- `handle` (read-only): `MapHandle<TMap>`.
- `slot` (read-only): `HTMLElement`. Element to render into (part="search" / `.lp-search`).
- `placeholder` (read-only): `string`.
- `label` (read-only): `string`.
- `select`: `(result: GeocodeResult): void`. Commit a result (source "search"; fires `input` then `change`).
### `SearchHandle`
Interface.
A mounted search UI.
**Members:**
- `element` (read-only): `HTMLElement`.
- `focus`: `(): void`.
- `clear`: `(): void`.
- `destroy`: `(): void`.
### `ProviderAdapter`
Interface.
A map engine adapter. Built-ins: `leafletProvider()`, `googleProvider()`, `maplibreProvider()`.
```ts
// OpenLayers in ~20 lines (see CONTRACT.md §4.5)
const openlayers: ProviderAdapter<OlMap> = { name: 'openlayers', mount(ctx) { ...; return handle; } };
new LocationPicker('#map', { provider: openlayers });
```
**Members:**
- `name` (read-only): `string`. Unique id; also the geocoder pairing key and `picker.provider`. Example: `"leaflet"`
- `load`: `(ctx: MountContext): Promise<void> | void`. Load the engine (idempotent, shared across pickers). Omit when the engine is always present.
- `mount`: `(ctx: MountContext): MapHandle<TMap> | Promise<MapHandle<TMap>>`. Create the map inside `ctx.container`. A synchronous return (engine present) makes `picker.map` available synchronously.
- `geocoder`: `Geocoder | ((ctx: MountContext, handle: MapHandle<TMap>) => Geocoder | Promise<Geocoder>)` (optional). Paired geocoder for `search`, `reverseGeocode`, `geocode()`, `lookupAddress()`. Default: undefined (no geocoding unless `geocoder` is set).
- `createSearch`: `(ctx: SearchContext<TMap>): SearchHandle | Promise<SearchHandle>`. Provider-rendered search UI. Without it the built-in combobox over `geocoder.search` is used.
## `location-picker/utils`
location-picker v3: NORMATIVE public declaration surface (FROZEN contract, 2026-10-16).
This single file declares every public entry point of the npm package as an ambient module block. The shipped
package splits the blocks into per-entry `.d.mts` / `.d.cts` files (see CONTRACT.md section 1). Where CONTRACT.md
prose and this file disagree, THIS FILE WINS for names, signatures, defaults and event shapes.
Packaging notes that this single-file form cannot express:
- The `/// <reference types="google.maps" />` directive below exists only so that this file type-checks as one unit.
In the shipped package it appears ONLY in `dist/google.d.mts` and `dist/google.d.cts` (LOCKED 10). No other
declaration file references google.maps; `@types/google.maps` is an OPTIONAL peer dependency.
- Relative specifiers in the shipped d.ts files use explicit extensions (`./core.mjs` in `.d.mts`, `./core.cjs` in
`.d.cts`). This is the spike-verified layout (`"type": "commonjs"`, tsdown `fixedExtension`), and it satisfies
the intent of LOCKED 10 ("explicit extensions, nodenext-safe").
- No declaration file imports CSS. `location-picker/style.css` has a stub (`export {};`).
- `HTMLElementTagNameMap` is augmented from ONE shared declaration file that both `element.d.mts` and `element.d.cts`
re-export, so a program that sees both ESM and CJS types never hits TS2717.
- The v1 module additionally ships `export as namespace locationPicker;` (UMD global); that statement is not legal
inside an ambient module block, so it appears here as a comment.
- JSX augmentations (`declare module 'react' { ... }` etc.) cannot be nested in ambient module blocks; each
`location-picker/jsx/*` block below documents the exact augmentation in an `@example`.
Conventions used everywhere:
- Latitude first, always. A value string is the canonical `"lat,lng"` (dot decimal, no spaces, `precision` decimals,
trailing zeros trimmed, never `-0`), e.g. `"48.85837,2.294481"`.
- Naming rule R3: class option `fooBar` == element property `fooBar` == element attribute `foo-bar` (hyphen-less
`foobar` also accepted). Event `x` == class callback `onX`. The ONE exception: the locate-control mode is the
option `locate` / attribute `locate`, but the readable PROPERTY is `locateMode`, because `locate()` is the method.
On the element, `locate` is an accessor: reading it returns the method; WRITING it (what React 19, Vue 3 and
Preact do for `locate="button"`, because `'locate' in el`) sets the `locate` attribute.
- Every non-hyphenated key of `LocationPickerAttributes` that exists on the element prototype has a setter, so
frameworks that assign properties when `key in el` never throw or clobber a method.
- Property writes are silent (no events), like native `<input>`. User gestures and METHOD calls emit `input` (final
value) then `change`, with `detail.source`.
### `LatLng`
Interface.
A WGS84 coordinate in decimal degrees, LATITUDE FIRST. Every value the library emits is normalised:
`lat` in [-90, 90] and `lng` in [-180, 180).
Example: `const eiffel: LatLng = { lat: 48.8584, lng: 2.2945 };`
**Members:**
- `lat`: `number`. Degrees north, in [-90, 90]. Example: `48.8584`
- `lng`: `number`. Degrees east, in [-180, 180). Example: `2.2945`
### `LatLngInput`
Type: `type LatLngInput = | string | LatLng | { lat: number; lon: number } | { latitude: number; longitude: number } | { lat(): number; lng(): number }`
Anything accepted where a position is expected (`value`, `center`, `setLocation()`, `parseLatLng()`).
- Strings are parsed with `parseLatLng` (lenient: "lat, lng", DMS, geo: URIs, map URLs).
- Objects: `{lat, lng}`, `{lat, lon}`, `{latitude, longitude}`, or any object with `lat()`/`lng()` methods
(google.maps.LatLng, structurally).
- ARRAYS ARE REJECTED on purpose: `[lat, lng]` vs GeoJSON `[lng, lat]` is ambiguous. Passing one logs
`INVALID_VALUE` with the fix "pass "lat,lng" or {lat, lng}".
Example: `"48.8584,2.2945"` Example: `{ lat: 48.8584, lng: 2.2945 }` Example: `{ latitude: 48.8584, longitude: 2.2945 }`
### `CoordinateFormat`
Type: `type CoordinateFormat = | 'decimal' | 'dms' | 'geo-uri' | 'google-maps-url' | 'osm-url' | 'apple-maps-url' | 'object'`
Which input syntax `parseLatLng` recognised.
### `ParsedLatLng`
Interface (`extends LatLng`).
Result of `parseLatLng`: a normalised coordinate plus what was recognised.
**Members:**
- `zoom`: `number` (optional). Zoom carried by the source (`@lat,lng,17z`, `#map=17/…`, `geo:…?z=17`), when present. Default: `undefined`.
- `format`: `CoordinateFormat`. The syntax that matched. Example: `"google-maps-url"`
### `parseLatLng`
Function: `parseLatLng(input: unknown): ParsedLatLng | null`
Lenient, offline coordinate parser. NEVER throws and NEVER calls the network. Returns `null` when the input is not
a coordinate, when latitude is outside [-90, 90] (no silent swapping), for arrays, and for short links.
The picker uses it for STRING input only (the `value`/`center` attributes, string `value`/`center`/`setLocation()`
arguments). Numeric and object input to the picker goes through `normalizeLatLng` instead, which clamps.
Accepts:
- Decimal pairs with `,` `;` or whitespace between: `"48.8584,2.2945"`, `"48.8584, 2.2945"`, `"48.8584 2.2945"`;
decimal commas when the pair separator is `;` or whitespace: `"48,8584; 2,2945"`.
- Hemisphere letters (letters decide the order): `"48.8584N 2.2945E"`, `"2.2945E 48.8584N"`, `"S 33.86 E 151.21"`.
- DMS / DDM: `48°51'30.2"N 2°17'40.2"E`, `N 48°51.503' E 2°17.670'`.
- RFC 5870 geo URIs: `"geo:48.8584,2.2945"`, `"geo:48.8584,2.2945;u=10?z=17"`, `"geo:0,0?q=48.8584,2.2945(Label)"`.
- Google Maps URLs: `!3dLAT!4dLNG` (wins over the viewport), `/@lat,lng,17z`, `?q=`, `?query=`, `?ll=`, `?api=1&query=`.
- OpenStreetMap URLs: `#map=17/lat/lng`, `?mlat=&mlon=`. Apple Maps URLs: `?ll=`, `?q=lat,lng`.
- Objects listed in `LatLngInput`.
Rejects (returns `null`): empty strings, `","`, arrays, `|lat| > 90`, `maps.app.goo.gl` / `goo.gl/maps` short
links, plus codes, what3words. Longitude is wrapped into [-180, 180).
Example: `parseLatLng('48.8584, 2.2945') // { lat: 48.8584, lng: 2.2945, format: 'decimal' }` Example: `parseLatLng('https://www.google.com/maps/@48.8584,2.2945,17z') // { lat: 48.8584, lng: 2.2945, zoom: 17, format: 'google-maps-url' }` Example: ``parseLatLng(`48°51'30.2"N 2°17'40.2"E`) // { lat: 48.858389, lng: 2.294500, format: 'dms' } (approx.)`` Example: `parseLatLng('not a place') // null`
### `FormatLatLngOptions`
Interface.
Options for `formatLatLng`.
**Members:**
- `format`: `'decimal' | 'dms'` (optional). Output syntax. Default: `'decimal'`. Example: `{ format: 'dms' }`
- `precision`: `number` (optional). Decimal places (decimal: of degrees; dms: of seconds). Default: 6 for decimal, 1 for dms. Example: `{ precision: 5 }`
- `separator`: `string` (optional). Separator between lat and lng for `decimal`. Default: `','`. Example: `{ separator: ', ' }`
### `formatLatLng`
Function: `formatLatLng(value: LatLngInput, options?: FormatLatLngOptions): string`
Locale-independent formatter. The default output is the canonical form value: latitude first, dot decimal,
`toFixed(precision)` with trailing zeros trimmed, no exponent, never `-0`. Rounds BEFORE normalising, so
`179.9999996` formats as `-180`. Throws `TypeError` for input that `parseLatLng` would reject.
Example: `formatLatLng({ lat: 48.85837009, lng: 2.29448128 }) // "48.85837,2.294481"` Example: `` formatLatLng({ lat: 48.8584, lng: 2.2945 }, { format: 'dms' }) // `48°51'30.2"N 2°17'40.2"E` ``
### `normalizeLatLng`
Function: `normalizeLatLng(value: LatLngInput): LatLng`
Wraps longitude into [-180, 180) and clamps latitude into [-90, 90] (fixes v2 issue #12; LOCKED 7). Accepts any
`LatLngInput`. Throws `TypeError` on non-finite or unparseable input. The picker applies it to every numeric
and object input (`setLocation(lat, lng)`, `{lat, lng}` values, the `lat`/`lng` options, attributes and
properties); when it clamps a latitude, the picker logs one console warning with a swap hint.
Example: `normalizeLatLng({ lat: 10, lng: 52283.36 }) // { lat: 10, lng: -36.64 } (approx.)` Example: `normalizeLatLng({ lat: 91, lng: 190 }) // { lat: 90, lng: -170 }`
### `isLatLng`
Function: `isLatLng(value: unknown): value is LatLng`
Type guard: a plain object with finite numeric `lat` in [-90, 90] and `lng` in [-180, 180].
Example: `if (isLatLng(x)) map.setView([x.lat, x.lng]);`
## `location-picker/leaflet`
### `LeafletMap`
Interface.
Minimal structural view of `L.Map`. Use `LocationPicker<L.Map>` with your own @types/leaflet for full typing.
**Members:**
- `getCenter`: `(): { lat: number; lng: number }`.
- `getZoom`: `(): number`.
- `getContainer`: `(): HTMLElement`.
- `remove`: `(): unknown`.
### `LeafletNamespace`
Interface.
Minimal structural view of the Leaflet namespace (1.9 and 2.x, constructor style).
**Members:**
- `version` (read-only): `string`.
- `Map` (read-only): `new (element: HTMLElement, options?: object) => unknown`.
- `TileLayer` (read-only): `new (url: string, options?: object) => unknown`.
### `LeafletProviderOptions`
Interface.
Options for `leafletProvider`; they win over the picker's provider options.
**Members:**
- `engine`: `LeafletNamespace | object` (optional). `import * as L from 'leaflet'`. Default: globalThis.L, else auto-load the pinned 1.9.4.
- `tileUrl`: `string` (optional). Default: `'https://tile.openstreetmap.org/{z}/{x}/{y}.png'`.
- `attribution`: `string` (optional). Default: `OSM_ATTRIBUTION`.
- `maxZoom`: `number` (optional). Default: `19`.
- `tileOptions`: `Record<string, unknown>` (optional). Extra `L.tileLayer` options. Default: `{}`.
- `engineUrl`: `string | false` (optional). Default: LEAFLET_JS_URL (with LEAFLET_JS_INTEGRITY); false = never auto-load.
- `engineCssUrl`: `string | false` (optional). Default: resolution order in CONTRACT.md §4.3; false = never link CSS.
- `mapOptions`: `Record<string, unknown>` (optional). Merged into `new L.Map(container, …)`, last. Default: `{}`.
### `LEAFLET_VERSION`
Constant: `LEAFLET_VERSION: '1.9.4'`
### `LEAFLET_JS_URL`
Constant: `LEAFLET_JS_URL: 'https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.js'`
### `LEAFLET_JS_INTEGRITY`
Constant: `LEAFLET_JS_INTEGRITY: 'sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo='`
### `LEAFLET_CSS_URL`
Constant: `LEAFLET_CSS_URL: 'https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css'`
### `LEAFLET_CSS_INTEGRITY`
Constant: `LEAFLET_CSS_INTEGRITY: 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY='`
### `OSM_TILE_URL`
Constant: `OSM_TILE_URL: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'`
### `OSM_ATTRIBUTION`
Constant: `OSM_ATTRIBUTION: string`
`'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'`
### `leafletProvider`
Function: `leafletProvider<TMap = LeafletMap>(options?: LeafletProviderOptions): ProviderAdapter<TMap>`
Leaflet + raster tiles adapter (the keyless default). Zoom is anchored at the centre (wheel, double-click, pinch),
so zooming never changes the value. Paired geocoder: Photon.
```ts
import * as L from 'leaflet';
import 'leaflet/dist/leaflet.css';
const picker = new LocationPicker<L.Map>('#map', { provider: leafletProvider<L.Map>({ engine: L }) });
picker.map; // L.Map, available synchronously
```
## `location-picker/google`
### `GoogleProviderOptions`
Interface.
Options for `googleProvider`; they win over the picker's provider options.
**Members:**
- `apiKey`: `string` (optional). Browser key. If google.maps is absent, Google's official inline bootstrap is injected with it.
- `mapId`: `string` (optional). Needed only for `marker: 'advanced'`.
- `version`: `string` (optional). Bootstrap `v`. Default: `'weekly'`.
- `language`: `string` (optional). Bootstrap `language`. Default: the picker's lang.
- `region`: `string` (optional). Bootstrap `region`. Example: `'LB'`
- `mapOptions`: `google.maps.MapOptions` (optional). Merged over the picker defaults (`clickableIcons: false`, `disableDefaultUI: true` + zoom control, `keyboardShortcuts: true`, `fullscreenControl: false`).
- `searchOptions`: `google.maps.places.PlaceAutocompleteElementOptions` (optional). Passed to `new PlaceAutocompleteElement(…)`. Example: `{ includedRegionCodes: ['lb'] }`
- `engine`: `typeof google.maps` (optional). Default: `globalThis.google.maps`.
### `googleProvider`
Function: `googleProvider(options?: GoogleProviderOptions): ProviderAdapter<google.maps.Map>`
Google Maps adapter: map via `importLibrary('maps')` (or a classic, already-loaded `google.maps.Map`, mounted
synchronously); CSS-overlay pin (no Marker, no mapId); AdvancedMarkerElement for `marker: 'advanced'`; search =
PlaceAutocompleteElement (`gmp-select` -> `placePrediction.toPlace()` -> `fetchFields({ fields: ['location',
'formattedAddress'] })`), never legacy `places.Autocomplete`; reverse = google.maps.Geocoder (opt-in). Wheel and
double-click zoom are re-anchored at the centre. `window.gm_authFailure` is chained into GOOGLE_AUTH_FAILED.
Example: `new LocationPicker<google.maps.Map>('#map', { provider: googleProvider({ apiKey: 'AIza…' }), search: true })`
### `googleGeocoder`
Function: `googleGeocoder(options?: { region?: string; language?: string }): Geocoder`
google.maps.Geocoder for reverse lookups and `geocode()` (Geocoding API). `pairsWith: ['google']`.
### `loadGoogleMaps`
Function: `loadGoogleMaps(options: { apiKey: string; version?: string; language?: string; region?: string; nonce?: string }): Promise<typeof google.maps>`
Idempotent: injects Google's official inline bootstrap once (nonce-aware) and resolves after `importLibrary('maps')`.
Example: `const maps = await loadGoogleMaps({ apiKey: 'AIza…' });`
### `GoogleLocationPicker`
Type: `type GoogleLocationPicker = LocationPicker<google.maps.Map>`
### `GoogleLocationPickerOptions`
Type: `type GoogleLocationPickerOptions = LocationPickerOptions<google.maps.Map>`
## `location-picker/maplibre`
### `MapLibreMap`
Interface.
Minimal structural view of `maplibregl.Map`.
**Members:**
- `getCenter`: `(): { lng: number; lat: number }`.
- `getZoom`: `(): number`.
- `getContainer`: `(): HTMLElement`.
- `remove`: `(): void`.
### `MapLibreProviderOptions`
Interface.
Options for `maplibreProvider`.
**Members:**
- `engine`: `object` (optional). `import * as maplibregl from 'maplibre-gl'`. Default: globalThis.maplibregl; otherwise ENGINE_MISSING.
- `mapStyle`: `string | object` (optional). Default: `OPENFREEMAP_STYLE_URL`.
- `engineUrl`: `string` (optional). Opt-in ESM URL loaded with import() (no SRI possible). Default: undefined (never loaded).
- `engineCssUrl`: `string | false` (optional). MapLibre CSS URL to load (opt-in; no SRI unless it is your own pinned file). By default nothing is fetched: readable document CSS is used (copied into shadow roots), else ENGINE_CSS_MISSING (warn) is reported with a fix that shows `import 'maplibre-gl/dist/maplibre-gl.css'` and the exact `engine-css-url` for the running `maplibregl.getVersion()`. Default: undefined (no fetch; `false` is equivalent).
- `mapOptions`: `Record<string, unknown>` (optional). Merged into `new maplibregl.Map(…)`, last. Default: `{}`.
### `OPENFREEMAP_STYLE_URL`
Constant: `OPENFREEMAP_STYLE_URL: 'https://tiles.openfreemap.org/styles/liberty'`
### `maplibreProvider`
Function: `maplibreProvider<TMap = MapLibreMap>(options?: MapLibreProviderOptions): ProviderAdapter<TMap>`
MapLibre GL adapter (v5/v6). Zoom around the centre; rotation off; `cooperative` maps to `cooperativeGestures`.
Paired geocoder: Photon. The public API stays lat-first; the adapter converts to [lng, lat].
```ts
import * as maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
new LocationPicker('#map', { provider: maplibreProvider({ engine: maplibregl }) });
```
## `location-picker/photon`
### `PhotonOptions`
Interface.
Options for `photonGeocoder`.
**Members:**
- `url`: `string` (optional). Search endpoint. Production apps should self-host Photon or use a commercial endpoint (the public one is fair-use, no SLA). Default: `'https://photon.komoot.io/api'`.
- `reverseUrl`: `string` (optional). Default: `'https://photon.komoot.io/reverse'`.
- `lang`: `string` (optional). Sent only when the endpoint supports it (public Photon: en, de, fr); otherwise omitted, giving local names. Default: the picker's lang when supported.
- `limit`: `number` (optional). Default: `5`.
- `debounce`: `number` (optional). Debounce for typed queries, ms. Values below 300 are raised to 300 on the public endpoint. Default: `300`.
- `minChars`: `number` (optional). Default: `3`.
- `params`: `Record<string, string>` (optional). Extra query params (e.g. `{ osm_tag: 'place' }`). Default: `{}`.
### `PHOTON_URL`
Constant: `PHOTON_URL: 'https://photon.komoot.io/api'`
### `photonGeocoder`
Function: `photonGeocoder(options?: PhotonOptions): Geocoder`
Photon (komoot) geocoder: typeahead search biased to the map centre, reverse lookup, LRU-cached, stale requests
aborted. `pairsWith: ['leaflet', 'maplibre']`. Nominatim is never a default; see docs for a custom geocoder recipe. (Usage policy: https://operations.osmfoundation.org/policies/nominatim/; recipe: [custom geocoders](https://github.com/cyphercodes/location-picker/blob/master/docs/providers.md#custom-geocoders).)
Example: `new LocationPicker('#map', { search: true, geocoder: photonGeocoder({ url: 'https://photon.example.org/api' }) })`
### `formatPhotonAddress`
Function: `formatPhotonAddress(properties: Record<string, unknown>): string`
Builds "name, housenumber street, postcode city, country" from Photon GeoJSON `properties`.
## `location-picker/webmcp`
### `WebMcpToolName`
Type: `type WebMcpToolName = 'get_location' | 'set_location' | 'search_places'`
### `WebMcpOptions`
Interface.
Options for `registerWebMcpTools`.
**Members:**
- `prefix`: `string` (optional). Tool-name prefix. Default: "location_picker", plus "_<id or name>" when the page has several pickers.
- `purpose`: `string` (optional). Sentence appended to every tool description (what the location is for). Default: `""`. Example: `{ purpose: 'Delivery drop-off point for this order.' }`
- `tools`: `readonly WebMcpToolName[]` (optional). Tools to register. `search_places` is skipped when no geocoder is available. There is never a locate tool (agents must not trigger permission prompts). Default: `['get_location', 'set_location', 'search_places']`.
- `signal`: `AbortSignal` (optional). Aborting unregisters the tools.
### `WebMcpRegistration`
Interface.
Result of `registerWebMcpTools`.
**Members:**
- `supported` (read-only): `boolean`. False when `document.modelContext` (or `navigator.modelContext`) is unavailable; then nothing was registered.
- `tools` (read-only): `readonly string[]`. Full names of the registered tools. Example: `['location_picker_get_location']`
- `unregister`: `(): void`. Idempotent.
### `isWebMcpSupported`
Function: `isWebMcpSupported(): boolean`
True when `document.modelContext.registerTool` (or the older `navigator.modelContext`) exists.
### `registerWebMcpTools`
Function: `registerWebMcpTools(target: LocationPicker | LocationPickerElement, options?: WebMcpOptions): WebMcpRegistration`
Registers tools via `document.modelContext.registerTool`:
- `<prefix>_get_location` (readOnlyHint): `{ value, lat, lng, address }` or `{ value: "" }`.
- `<prefix>_set_location`: input `{ lat, lng }` or `{ text }` (text -> parseLatLng, else the paired geocoder's first result); commits with source "api".
- `<prefix>_search_places` (readOnlyHint): `{ query }` -> up to 5 results, not committed.
Never throws; returns `{ supported: false }` when WebMCP is unavailable.
Example: `const { supported } = registerWebMcpTools(document.querySelector('location-picker')!, { purpose: 'Where the sensor is installed.' });`
## `location-picker/global`
`<script src="https://cdn.jsdelivr.net/npm/location-picker@3"></script>` sets `window.LocationPicker` to the CLASS
itself (so `new LocationPicker('#map')` works) with every public export attached as a static, and defines
`<location-picker>`. It never writes the lowercase v1 global `locationPicker`. Self-contained: core, element, all
three adapters, search, Photon and WebMCP in one file with no chunks (safe to vendor).
TS users of the script build: `/// <reference types="location-picker/global" />`. This needs NO `@types/google.maps`:
the Google statics are typed structurally here. Programs that also `import type {} from 'location-picker/google'`
(which needs `@types/google.maps`) get the precise Google overloads through interface merging (see the
`location-picker/google` augmentation block below). That augmentation does not pull this global into ESM programs.
SHIPPED INSIDE `dist/google.d.mts` and `dist/google.d.cts` (not in `global.d.ts`): precise overloads merged into
the global's Google statics. Method merging puts these overloads FIRST, so a program that has both
`location-picker/global` and `location-picker/google` gets `ProviderAdapter<google.maps.Map>`. A program that only
imports `location-picker/google` does not load `global.d.ts` (verified on TS 7.0.2: no `LocationPicker` global leaks).
Written as a separate block here only because ambient module blocks cannot nest.
### `LocationPickerGoogleStatics`
Interface.
Google statics, structurally typed (no google.maps types). Declared as METHODS so `location-picker/google` can merge precise overloads.
**Members:**
- `googleProvider`: `(options?: { apiKey?: string; mapId?: string; version?: string; language?: string; region?: string; mapOptions?: Record<string, unknown>; searchOptions?: Record<string, unknown>; engine?: object; }): Core.ProviderAdapter`. See `googleProvider` in `location-picker/google`..
- `googleGeocoder`: `(options?: { region?: string; language?: string }): Core.Geocoder`. See `googleGeocoder` in `location-picker/google`..
- `loadGoogleMaps`: `(options: { apiKey: string; version?: string; language?: string; region?: string; nonce?: string }): Promise<unknown>`. See `loadGoogleMaps` in `location-picker/google`. Resolves to the `google.maps` namespace..
- `googleProvider`: `(options?: GoogleProviderOptions): ProviderAdapter<google.maps.Map>`.
- `googleGeocoder`: `(options?: { region?: string; language?: string }): Geocoder`.
- `loadGoogleMaps`: `(options: { apiKey: string; version?: string; language?: string; region?: string; nonce?: string }): Promise<typeof google.maps>`.
### `LocationPickerStatics`
Interface (`extends LocationPickerGoogleStatics`).
Every public runtime export of the package, attached to the IIFE global as a static (D12).
**Members:**
- `LocationPicker` (read-only): `typeof Core.LocationPicker`.
- `LocationPickerElement` (read-only): `typeof Element.LocationPickerElement`.
- `LocationPickerError` (read-only): `typeof Core.LocationPickerError`.
- `defineLocationPicker` (read-only): `typeof Element.defineLocationPicker`.
- `parseLatLng` (read-only): `typeof Core.parseLatLng`.
- `formatLatLng` (read-only): `typeof Core.formatLatLng`.
- `normalizeLatLng` (read-only): `typeof Core.normalizeLatLng`.
- `isLatLng` (read-only): `typeof Core.isLatLng`.
- `leafletProvider` (read-only): `typeof Leaflet.leafletProvider`.
- `maplibreProvider` (read-only): `typeof MapLibre.maplibreProvider`.
- `photonGeocoder` (read-only): `typeof Photon.photonGeocoder`.
- `formatPhotonAddress` (read-only): `typeof Photon.formatPhotonAddress`.
- `registerWebMcpTools` (read-only): `typeof WebMcp.registerWebMcpTools`.
- `isWebMcpSupported` (read-only): `typeof WebMcp.isWebMcpSupported`.
- `LEAFLET_VERSION` (read-only): `typeof Leaflet.LEAFLET_VERSION`.
- `LEAFLET_JS_URL` (read-only): `typeof Leaflet.LEAFLET_JS_URL`.
- `LEAFLET_JS_INTEGRITY` (read-only): `typeof Leaflet.LEAFLET_JS_INTEGRITY`.
- `LEAFLET_CSS_URL` (read-only): `typeof Leaflet.LEAFLET_CSS_URL`.
- `LEAFLET_CSS_INTEGRITY` (read-only): `typeof Leaflet.LEAFLET_CSS_INTEGRITY`.
- `OSM_TILE_URL` (read-only): `typeof Leaflet.OSM_TILE_URL`.
- `OSM_ATTRIBUTION` (read-only): `typeof Leaflet.OSM_ATTRIBUTION`.
- `OPENFREEMAP_STYLE_URL` (read-only): `typeof MapLibre.OPENFREEMAP_STYLE_URL`.
- `PHOTON_URL` (read-only): `typeof Photon.PHOTON_URL`.
### `LocationPickerGlobal`
Type: `type LocationPickerGlobal = typeof Core.LocationPicker & LocationPickerStatics`
The IIFE global: the class (with its static `version`) plus every public export as a static.
### `LocationPicker`
Global augmentation (`declare global`).
Declaration: `var LocationPicker: LocationPickerGlobal`.
## `location-picker/style.css`
`import 'location-picker/style.css'` — optional; needed only with `injectStyles: false` (strict CSP, class mode).
- `export {}`
## `location-picker/v1`
Types for the frozen v1-compatible file (global lowercase `locationPicker`, CommonJS `module.exports = class`, AMD).
Deliberately references NO google.maps types (LOCKED 10): `map` is `any` (it is a google.maps.Map).
The shipped `dist/location-picker.min.d.ts` also contains `export as namespace locationPicker;`.
```ts
// npm users of 1.x: change one import
import locationPicker from 'location-picker/v1';
const lp = new locationPicker('map', { setCurrentPosition: false, lat: 48.8584, lng: 2.2945 });
google.maps.event.addListener(lp.map, 'idle', () => console.log(lp.getMarkerPosition()));
```
- `export = locationPicker`
### `Options`
Namespace `locationPicker`.
v1 options.
**Members:**
- `setCurrentPosition`: `boolean` (optional). Centre on the user's position when neither `lat` nor `lng` is given. Default: `true`.
- `lat`: `number` (optional). Default: 34.4346 (Tripoli) when not given.
- `lng`: `number` (optional). Default: 35.8362 (Tripoli) when not given.
### `locationPicker`
Class (augmentation).
v1 picker over a classic, already-loaded Google Maps script. Creates `.map` SYNCHRONOUSLY.
**Members:**
- `constructor`: `(element: string | HTMLElement, options?: locationPicker.Options, mapOptions?: object)`. Parameter `element`: element id or HTMLElement. Parameter `options`: v1 options. Parameter `mapOptions`: google.maps.MapOptions merged over `{ center, zoom: 15 }`.
- `map`: `any`. The google.maps.Map, created synchronously.
- `element`: `HTMLElement | null`.
- `getMarkerPosition`: `(): { lat: number; lng: number }`. Map centre; longitude wrapped to [-180, 180), latitude clamped (#12).
- `setLocation`: `(lat: number, lng: number): void`.
- `setCurrentPosition`: `(): void`. Never rejects; logs like v1 on failure.
## `location-picker/dist/location-picker.min.js`
- `export = locationPicker`
## `location-picker/jsx/react`
React 19 JSX typings. Usage: `import type {} from 'location-picker/jsx/react';` once.
React 19 sets `value`, `search`, `locate`, `form`… as PROPERTIES when they exist on the element (each has a setter)
and fires `onChange`/`onInput` for the element's native-named events (verified on react-dom 19.3 with createRoot).
Spell primitive config in kebab-case (SSR-correct).
Event props in React: `onChange`, `onInput` (synthetic), and the LOWERCASE native `onaddresschange`, `onready`,
`onerror`. `onAddressChange`/`onReady` listen for events that never fire and `onError` is React's own synthetic
event (never fired for this element), so all three are type errors here.
Hydration: with `hydrateRoot` (Next.js App Router, Remix), React applies neither object props (`messages`,
`mapOptions`, `engine`, `geocoder`, `geolocation`, an adapter-object `provider`) nor the lowercase native handlers.
Set them in a ref callback (refs ARE attached on hydration); attributes, `value` and `onChange`/`onInput` work.
```ts
// shipped dist/jsx/react.d.ts
import type { LocationPickerAttributes, LocationPickerElement } from '../element.mjs';
import type { DetailedHTMLProps, HTMLAttributes, SyntheticEvent } from 'react';
declare module 'react' { namespace JSX { interface IntrinsicElements {
'location-picker': Omit<DetailedHTMLProps<HTMLAttributes<LocationPickerElement>, LocationPickerElement>, 'onChange' | 'onInput' | 'onError' | 'onLoad' | 'placeholder'>
& LocationPickerAttributes & {
onChange?: (e: SyntheticEvent<LocationPickerElement, CustomEvent<LocationDetail>>) => void;
onInput?: (e: SyntheticEvent<LocationPickerElement, CustomEvent<LocationDetail>>) => void;
onaddresschange?: (e: CustomEvent<AddressDetail>) => void; onready?: (e: CustomEvent<ReadyDetail>) => void;
onerror?: (e: CustomEvent<LocationPickerError>) => void;
messages?: Partial<Messages>; mapOptions?: Record<string, unknown>; engine?: object; geocoder?: Geocoder | GeocodeFn | false;
geolocation?: PositionOptions;
} } } }
```
```ts
// SSR-hydrated React: object props and native listeners via a ref callback (React 19 cleanup function)
<location-picker name="location" reverse-geocode ref={(el) => {
if (!el) return;
el.messages = { locate: 'Ma position' };
const onAddress = (e: CustomEvent<AddressDetail>) => setAddress(e.detail.address);
el.addEventListener('addresschange', onAddress);
return () => el.removeEventListener('addresschange', onAddress);
}} />
```
- `export {}`
## `location-picker/jsx/preact`
Preact JSX typings (`preact` JSX.IntrinsicElements; same attribute set, `onchange`/`oninput` lowercase handlers).
- `export {}`
## `location-picker/jsx/solid`
Solid JSX typings (`solid-js` JSX.IntrinsicElements; `prop:value`, `on:change`, `on:addresschange`).
- `export {}`
## `location-picker/jsx/vue`
Vue typings (`vue` GlobalComponents; `v-model` = `input` + `el.value`, `v-model.lazy` = `change`).
- `export {}`
## `location-picker/jsx/svelte`
Svelte 5 typings (`svelte/elements` SvelteHTMLElements; `onchange`, `oninput`, `onaddresschange`).
- `export {}`
In this repository3 other files it gives its agents.
- AGENTS.mdAGENTS.md
- site/llms.txtllms.txt
- location-pickerSkill
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

