Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4199e6b
feat(leaflet): add Leaflet map integration
harlan-zw Jul 20, 2026
e3ca96c
fix(leaflet): type the loaded API callback
harlan-zw Jul 20, 2026
3d9daa2
fix(leaflet): preserve loader errors and event types
harlan-zw Jul 20, 2026
88363b9
feat(maplibre): add vector map integration
harlan-zw Jul 20, 2026
789b22b
fix(leaflet): clean up children before map removal
harlan-zw Jul 20, 2026
e0c421f
fix(types): handle uppercase script blocks
harlan-zw Jul 20, 2026
1f919a1
fix(types): parse script blocks without html regex
harlan-zw Jul 20, 2026
ef745c5
fix(maps): harden lifecycle and option updates
harlan-zw Jul 20, 2026
9c3264b
feat(maps): add real-world examples
harlan-zw Jul 20, 2026
93c1ed2
docs(maps): align guides with Google Maps
harlan-zw Jul 20, 2026
73f6cd8
Merge remote-tracking branch 'origin/main' into feat/leaflet
harlan-zw Jul 21, 2026
022b123
Merge remote-tracking branch 'origin/main' into feat/leaflet
harlan-zw Jul 21, 2026
1a1d7df
Merge remote-tracking branch 'origin/main' into feat/leaflet
harlan-zw Jul 21, 2026
eb0d813
Merge remote-tracking branch 'origin/main' into feat/leaflet
harlan-zw Jul 21, 2026
6780667
Merge remote-tracking branch 'origin/main' into feat/leaflet
harlan-zw Aug 4, 2026
c44140b
Merge remote-tracking branch 'origin/main' into feat/leaflet
harlan-zw Aug 4, 2026
60c96e9
fix(maplibre): initialize GeoJSON after map load
harlan-zw Aug 4, 2026
5cd2404
docs(maps): add inline real-world examples
harlan-zw Aug 4, 2026
fb2777d
Merge remote-tracking branch 'origin/main' into feat/leaflet
harlan-zw Aug 4, 2026
bd20dd3
Merge remote-tracking branch 'origin/main' into feat/leaflet
harlan-zw Aug 4, 2026
e366677
Merge remote-tracking branch 'origin/main' into feat/leaflet
harlan-zw Aug 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions docs/content/scripts/leaflet/1.guides/1.pickup-locator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
---
title: "Example: Pickup Locator"
description: Build a production-shaped store locator with accessible selection, map fallbacks, popups, and a delivery zone.
---

This real-world pickup locator pairs a location list with the map. The list keeps addresses and opening hours available when tiles fail, and it gives keyboard users a direct way to choose a location.

It demonstrates reactive selection, accessible location buttons, explicit OpenStreetMap attribution, marker popups, error fallback content, and a GeoJSON delivery zone.

::code-group

:leaflet-pickup-locator-demo{label="Demo"}

```vue [Source]
<script setup lang="ts">
import type { Feature, Polygon } from 'geojson'
import type { LatLngExpression, LatLngTuple } from 'leaflet'

interface PickupLocation {
id: string
name: string
address: string
hours: string
position: LatLngTuple
}

const locations: PickupLocation[] = [
{
id: 'flinders-lane',
name: 'Flinders Lane',
address: 'Centre Place, Melbourne VIC',
hours: 'Open today until 6 pm',
position: [-37.8164, 144.9656],
},
{
id: 'queen-victoria-market',
name: 'Queen Victoria Market',
address: 'Queen Street, Melbourne VIC',
hours: 'Open today until 3 pm',
position: [-37.8076, 144.9568],
},
{
id: 'southbank',
name: 'Southbank',
address: 'Southbank Promenade, Southbank VIC',
hours: 'Open today until 8 pm',
position: [-37.8202, 144.9655],
},
]

const selectedId = ref(locations[0]!.id)
const center = ref<LatLngExpression>(locations[0]!.position)
const zoom = ref(14)

const deliveryZone: Feature<Polygon> = {
type: 'Feature',
properties: { name: 'Same-day delivery zone' },
geometry: {
type: 'Polygon',
coordinates: [[
[144.946, -37.802],
[144.982, -37.802],
[144.986, -37.828],
[144.951, -37.832],
[144.946, -37.802],
]],
},
}

function selectLocation(location: PickupLocation) {
selectedId.value = location.id
center.value = location.position
zoom.value = 16
}
</script>

<template>
<div class="pickup-locator">
<aside aria-labelledby="pickup-title">
<h2 id="pickup-title">
Pickup in Melbourne
</h2>

<ol>
<li v-for="location in locations" :key="location.id">
<button
type="button"
:aria-pressed="selectedId === location.id"
@click="selectLocation(location)"
>
<strong>{{ location.name }}</strong>
<span>{{ location.address }}</span>
<small>{{ location.hours }}</small>
</button>
</li>
</ol>
</aside>

<ScriptLeafletMap
v-model:center="center"
v-model:zoom="zoom"
width="100%"
:height="520"
aria-label="Pickup locations and delivery zone in central Melbourne"
>
<template #error>
<p role="alert">
The map is unavailable. Choose a pickup location from the list.
</p>
</template>

<ScriptLeafletTileLayer
url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
:options="{
maxZoom: 19,
attribution: '&copy; <a href=&quot;https://www.openstreetmap.org/copyright&quot;>OpenStreetMap contributors</a>',
}"
/>

<ScriptLeafletGeoJson
:data="deliveryZone"
:options="{
style: {
color: '#15803d',
fillColor: '#86efac',
fillOpacity: 0.18,
weight: 2,
},
}"
/>

<ScriptLeafletMarker
v-for="location in locations"
:key="location.id"
:position="location.position"
:alt="`${location.name} pickup location`"
:title="location.name"
@click="selectLocation(location)"
>
<ScriptLeafletPopup
:open="selectedId === location.id"
:options="{ minWidth: 180 }"
>
<strong>{{ location.name }}</strong><br>
{{ location.address }}<br>
{{ location.hours }}
</ScriptLeafletPopup>
</ScriptLeafletMarker>
</ScriptLeafletMap>
</div>
</template>
```

::

Use CSS Grid to place the list beside the map on wide screens and above it on small screens. Keep the list in the document even when the map is the main visual.

GeoJSON coordinates use `[longitude, latitude]`; Leaflet marker positions use `[latitude, longitude]`.
80 changes: 80 additions & 0 deletions docs/content/scripts/leaflet/1.guides/2.tiles-and-attribution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
---
title: Tiles & Attribution
---

Leaflet renders and controls the map, but it does not supply map imagery. Add a `ScriptLeafletTileLayer` for each raster tile service you use.

## Choose a Tile Service

A tile URL usually contains `{z}`, `{x}`, and `{y}` placeholders:

```vue
<ScriptLeafletTileLayer
url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
:options="{
maxZoom: 19,
attribution: '&copy; <a href=&quot;https://www.openstreetmap.org/copyright&quot;>OpenStreetMap contributors</a>',
}"
/>
```

For production, check the service's traffic limits, caching rules, allowed use cases, privacy policy, and availability commitments. Some providers require an API key or account. Others offer downloadable tiles for self-hosting.

Keep the URL configurable so you can change providers without rewriting the map component:

```ts [nuxt.config.ts]
export default defineNuxtConfig({
runtimeConfig: {
public: {
mapTileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
},
},
})
```

```vue
<script setup lang="ts">
const config = useRuntimeConfig()
const tileAttribution = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>'
</script>

<template>
<ScriptLeafletTileLayer
:url="config.public.mapTileUrl"
:options="{ attribution: tileAttribution }"
/>
</template>
```

## OpenStreetMap's Standard Tiles

OpenStreetMap data is open, but the standard tile servers are a donation-funded public service. They have no SLA and may block clients that break the [tile usage policy](https://operations.osmfoundation.org/policies/tiles/).

When using `tile.openstreetmap.org`:

- Keep `Β© OpenStreetMap contributors` visible on the map.
- Use the official HTTPS tile URL and allow the browser to send its normal referrer.
- Respect HTTP caching headers. Do not force tiles to bypass the browser cache.
- Do not bulk download, prefetch areas, or add offline download features.

Normal browser-based map viewing follows the technical caching and identification requirements. A proxy, native client, or offline feature needs additional work and may need a different provider.

## Attribution

Pass the provider's required attribution through the tile layer `options`. Leaflet combines attribution from active layers in its attribution control.

Do not cover the control with page UI or remove it. If your map uses several data or imagery sources, include the attribution required by each source.

## Failure Fallback

Tile services can reject requests or become unavailable while the rest of the page still works. Keep essential locations in HTML and provide a useful error state:

```vue
<ScriptLeafletMap :center="[-37.8136, 144.9631]" :zoom="13">
<template #error>
<p role="alert">
The map is unavailable. Use the location list to choose a store.
</p>
</template>
</ScriptLeafletMap>
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
---
title: Performance & Accessibility
---

`ScriptLeafletMap` loads near the viewport by default. It also reserves the configured dimensions during SSR, which prevents the page from shifting when Leaflet starts.

## Loading Strategies

### Visible by Default

The default `visible` trigger delays the SDK, stylesheet, and tile requests until the map approaches the viewport:

```vue
<ScriptLeafletMap :center="[-37.8136, 144.9631]" :zoom="13" />
```

### Immediate Loading

Load immediately when the map is the page's primary content:

```vue
<ScriptLeafletMap
trigger="immediate"
:center="[-37.8136, 144.9631]"
:zoom="13"
/>
```

You can also use an [element event trigger](/docs/guides/script-triggers#element-event-triggers) such as `mousedown` when the map should load only after explicit interaction.

## Stable Layout and Loading States

Always give the map a height. A number is treated as pixels; a CSS length works well for responsive layouts:

```vue
<ScriptLeafletMap
width="100%"
height="clamp(24rem, 60vw, 36rem)"
:center="[-37.8136, 144.9631]"
/>
```

Use the `placeholder`, `loading`, and `error` slots when the default states do not fit your page. Put addresses, opening hours, and other essential details outside the map so they remain available before loading and after an error.

## Interactive Maps

Give the map a specific `aria-label` and each marker a unique `alt`. Leaflet's keyboard controls remain available after the map loads.

```vue
<ScriptLeafletMap
:center="[-37.8136, 144.9631]"
:zoom="13"
aria-label="Pickup locations in central Melbourne"
>
<ScriptLeafletMarker
:position="[-37.8164, 144.9656]"
alt="Flinders Lane pickup location"
/>
</ScriptLeafletMap>
```

A text list should duplicate any location a user must discover or select. A visual marker alone is not enough for store selection, delivery status, or directions.

## Decorative Maps

Set `:interactive="false"` when the map is only decoration. The component disables map input, hides it from assistive technology, and makes child controls inert.

```vue
<ScriptLeafletMap
:center="[-37.8136, 144.9631]"
:zoom="13"
:interactive="false"
/>
```

## Custom Styles

Nuxt Scripts injects its embedded Leaflet stylesheet when the SDK starts loading. If your app already provides Leaflet CSS, disable the embedded copy:

```ts [nuxt.config.ts]
export default defineNuxtConfig({
css: ['leaflet/dist/leaflet.css'],
})
```

```vue
<ScriptLeafletMap
:center="[-37.8136, 144.9631]"
:inject-styles="false"
/>
```

Use the same approach when an inline-style Content Security Policy prevents the default stylesheet injection.
22 changes: 22 additions & 0 deletions docs/content/scripts/leaflet/2.api/1.script-leaflet-map.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
title: <ScriptLeafletMap>
---

The map facade reserves layout space during SSR, loads Leaflet when triggered, creates the `L.Map`, and provides it to child components.

::script-types{script-key="leaflet" filter="ScriptLeafletMap"}
::

`center` and `zoom` are reactive. Use `v-model:center` or `v-model:zoom` when you also want to receive user pan and zoom changes.

```vue
<ScriptLeafletMap
v-model:center="center"
v-model:zoom="zoom"
width="100%"
height="28rem"
@ready="({ map }) => console.log(map.value)"
/>
```

The `placeholder`, `awaitingLoad`, `loading`, and `error` slots customize each loading state. The default error state is visible and announced with `role="alert"`.
10 changes: 10 additions & 0 deletions docs/content/scripts/leaflet/2.api/2.tile-layer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
title: <ScriptLeafletTileLayer>
---

Adds an `L.TileLayer` to the nearest parent map. Nuxt Scripts does not choose a tile provider for you.

::script-types{script-key="leaflet" filter="ScriptLeafletTileLayer"}
::

Always follow the selected provider's terms and pass its required attribution through `options.attribution`.
10 changes: 10 additions & 0 deletions docs/content/scripts/leaflet/2.api/3.marker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
title: <ScriptLeafletMarker>
---

Adds a reactive `L.Marker`. Supply a unique `alt` so the generated marker image has a useful accessible name.

::script-types{script-key="leaflet" filter="ScriptLeafletMarker"}
::

Nest a [`<ScriptLeafletPopup>`{lang="html"}](/scripts/leaflet/api/popup) in the default slot to bind it to the marker.
8 changes: 8 additions & 0 deletions docs/content/scripts/leaflet/2.api/4.popup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: <ScriptLeafletPopup>
---

Renders slotted HTML in an `L.Popup`. Nest it inside a marker, or pass `position` for a standalone popup. Use the reactive `open` prop to control visibility.

::script-types{script-key="leaflet" filter="ScriptLeafletPopup"}
::
10 changes: 10 additions & 0 deletions docs/content/scripts/leaflet/2.api/5.geojson.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
title: <ScriptLeafletGeoJson>
---

Adds inline GeoJSON through `L.geoJSON`. Replacing `data` clears and repopulates the existing layer; changing `options.style` updates its path style.

::script-types{script-key="leaflet" filter="ScriptLeafletGeoJson"}
::

Remote fetching is intentionally outside the component. Fetch and validate data with `useFetch`, then pass the resulting object to `data` so loading and failure states remain explicit.
Loading
Loading