diff --git a/docs/content/scripts/maplibre/2.api/10.attribution-control.md b/docs/content/scripts/maplibre/2.api/10.attribution-control.md new file mode 100644 index 000000000..45dd17c2f --- /dev/null +++ b/docs/content/scripts/maplibre/2.api/10.attribution-control.md @@ -0,0 +1,46 @@ +--- +title: +--- + +Moves or restyles the map's attribution. Use it to change the attribution position, force compact mode, or add your own credits. + +```vue + +``` + +::callout{color="amber"} +Most tile providers require attribution. OpenFreeMap and OpenStreetMap require it. Keep an attribution control on every map that shows their tiles. +:: + +## How it works with the default attribution + +MapLibre adds an attribution control to every map by default. Two controls would show the attribution twice. This component prevents that: + +- When the component mounts, it removes the map's attribution control and adds its own. +- When the component unmounts, it adds the map's control back. This also happens if your code removed the component's control first. + +The map therefore shows attribution exactly once. Unmounting the component does not remove the default attribution. + +If you set `attributionControl: false` in the map `options`, the map has no control to restore. The component then adds its own control, and unmounting removes it. + +Use one ``{lang="html"} per map. The map's control returns to its default position, bottom-right, when the component unmounts. + +## Attribution text + +The control always shows the attribution from the style sources, for example OpenFreeMap and OpenStreetMap. `options` cannot remove it. + +The component `options` override the options of the map's attribution control. Options you omit keep the map's values. + +If you omit `options.customAttribution`, the credits from the map's control stay, including MapLibre's default "MapLibre" link. If you set it, your value replaces them. + +`new maplibregl.AttributionControl()`{lang="ts"} reads `options` and `position` once, when it creates the control. To apply a change, change the component `key`. + +::script-types{script-key="maplibre" filter="ScriptMapLibreAttributionControl"} +:: diff --git a/docs/content/scripts/maplibre/2.api/7.scale-control.md b/docs/content/scripts/maplibre/2.api/7.scale-control.md new file mode 100644 index 000000000..b16f2949b --- /dev/null +++ b/docs/content/scripts/maplibre/2.api/7.scale-control.md @@ -0,0 +1,18 @@ +--- +title: +--- + +Adds MapLibre's scale bar to the nearest parent map. MapLibre places it in the bottom-left corner by default. + +```vue + +``` + +`new maplibregl.ScaleControl()`{lang="ts"} reads `options` and `position` once, when it creates the control. `options.unit` also updates reactively through `setUnit()`{lang="ts"}. To apply any other change, change the component `key`. + +::script-types{script-key="maplibre" filter="ScriptMapLibreScaleControl"} +:: diff --git a/docs/content/scripts/maplibre/2.api/8.geolocate-control.md b/docs/content/scripts/maplibre/2.api/8.geolocate-control.md new file mode 100644 index 000000000..8a11671ef --- /dev/null +++ b/docs/content/scripts/maplibre/2.api/8.geolocate-control.md @@ -0,0 +1,58 @@ +--- +title: +--- + +Adds MapLibre's geolocate button to the nearest parent map. The button asks the browser for the user's location and moves the camera there. + +```vue + + + +``` + +`new maplibregl.GeolocateControl()`{lang="ts"} reads `options` and `position` once, when it creates the control. To apply a change, change the component `key`. + +The component exposes the MapLibre control as `control`. Call `control.value.trigger()`{lang="ts"} to request the location from code. + +## Events + +The component emits every `GeolocateControl` event with MapLibre's event object: + +- `geolocate` fires when the browser returns a position. +- `error` fires when the browser returns an error. +- `outofmaxbounds` fires when the position is outside the map's `maxBounds`. +- `trackuserlocationstart` and `trackuserlocationend` fire when tracking starts and stops. They need `trackUserLocation`. +- `userlocationfocus` and `userlocationlostfocus` fire when the camera locks to or leaves the user's location. + +## Permission denied + +Geolocation can fail in two ways. The component reports both. + +- If the user denies the permission prompt, `error` fires with `code` 1. MapLibre then disables the button. +- If the permission was already denied when the control loaded, MapLibre disables the button and fires no event. The component emits `unavailable` with `'permission-denied'`. +- If the browser has no Geolocation API, the component emits `unavailable` with `'unsupported'`. + +Geolocation needs a secure context. On a plain HTTP origin other than `localhost`, the browser denies it. + +::script-types{script-key="maplibre" filter="ScriptMapLibreGeolocateControl"} +:: diff --git a/docs/content/scripts/maplibre/2.api/9.fullscreen-control.md b/docs/content/scripts/maplibre/2.api/9.fullscreen-control.md new file mode 100644 index 000000000..3748a546d --- /dev/null +++ b/docs/content/scripts/maplibre/2.api/9.fullscreen-control.md @@ -0,0 +1,24 @@ +--- +title: +--- + +Adds MapLibre's fullscreen button to the nearest parent map. The map container goes fullscreen by default. Pass `options.container` to make a different element fullscreen. + +```vue + +``` + +`new maplibregl.FullscreenControl()`{lang="ts"} reads `options` and `position` once, when it creates the control. To apply a change, change the component `key`. + +If the browser has no Fullscreen API, or `options.pseudo` is `true`, MapLibre expands the map with CSS instead. + +::script-types{script-key="maplibre" filter="ScriptMapLibreFullscreenControl"} +:: diff --git a/docs/content/scripts/maplibre/index.md b/docs/content/scripts/maplibre/index.md index ded3fc3ec..860546ec4 100644 --- a/docs/content/scripts/maplibre/index.md +++ b/docs/content/scripts/maplibre/index.md @@ -93,6 +93,10 @@ OpenFreeMap's public instance needs no API key, but it has no SLA. Read [Styles - [``{lang="html"}](/scripts/maplibre/api/marker) adds an accessible, reactive marker. - [``{lang="html"}](/scripts/maplibre/api/popup) binds slotted HTML to a marker or coordinate. - [``{lang="html"}](/scripts/maplibre/api/navigation-control) adds zoom, compass, and pitch controls. +- [``{lang="html"}](/scripts/maplibre/api/scale-control) adds a scale bar. +- [``{lang="html"}](/scripts/maplibre/api/geolocate-control) finds the user's location. +- [``{lang="html"}](/scripts/maplibre/api/fullscreen-control) toggles fullscreen mode. +- [``{lang="html"}](/scripts/maplibre/api/attribution-control) moves or restyles the map attribution. - [``{lang="html"}](/scripts/maplibre/api/geojson) manages a GeoJSON source and its style layers, including [clustering](/scripts/maplibre/api/geojson#clustering). ## Guides diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index 8fe7f2637..12c5c7956 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -3,10 +3,15 @@ import type { ProxyDevtoolsScript } from './devtools' import type { NormalizedRegistryEntry } from './normalize' import type { ProxyAliasConfig } from './proxy-alias' import type { + ScriptMapLibreAttributionControlProps, + ScriptMapLibreFullscreenControlEmits, + ScriptMapLibreFullscreenControlProps, ScriptMapLibreGeoJsonEmits, ScriptMapLibreGeoJsonLayer, ScriptMapLibreGeoJsonProps, ScriptMapLibreGeoJsonResource, + ScriptMapLibreGeolocateControlEmits, + ScriptMapLibreGeolocateControlProps, ScriptMapLibreMapEmits, ScriptMapLibreMapExpose, ScriptMapLibreMapProps, @@ -16,6 +21,7 @@ import type { ScriptMapLibreNavigationControlProps, ScriptMapLibrePopupEmits, ScriptMapLibrePopupProps, + ScriptMapLibreScaleControlProps, } from './runtime/components/MapLibre/types' import type { ProxyPrivacyInput } from './runtime/server/utils/privacy' import type { @@ -63,10 +69,15 @@ import { validateScriptsEnvVars } from './validate-env' export type { FirstPartyPrivacy } export type { + ScriptMapLibreAttributionControlProps, + ScriptMapLibreFullscreenControlEmits, + ScriptMapLibreFullscreenControlProps, ScriptMapLibreGeoJsonEmits, ScriptMapLibreGeoJsonLayer, ScriptMapLibreGeoJsonProps, ScriptMapLibreGeoJsonResource, + ScriptMapLibreGeolocateControlEmits, + ScriptMapLibreGeolocateControlProps, ScriptMapLibreMapEmits, ScriptMapLibreMapExpose, ScriptMapLibreMapProps, @@ -76,6 +87,7 @@ export type { ScriptMapLibreNavigationControlProps, ScriptMapLibrePopupEmits, ScriptMapLibrePopupProps, + ScriptMapLibreScaleControlProps, } const UPPER_RE = /([A-Z])/g diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index 7302e81d7..16d830c66 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -839,6 +839,21 @@ "kind": "interface", "code": "export interface MapLibreApi {\n maplibregl: typeof MapLibre\n}" }, + { + "name": "ScriptMapLibreAttributionControlProps", + "kind": "interface", + "code": "interface ScriptMapLibreAttributionControlProps {\n /** Position of the attribution control. MapLibre places it bottom-right by default. */\n position?: MapLibre.ControlPosition\n /**\n * Options passed to `new maplibregl.AttributionControl()`.\n * Source attribution from the style and tiles always shows. `customAttribution` only adds text.\n */\n options?: MapLibre.AttributionControlOptions\n}" + }, + { + "name": "ScriptMapLibreFullscreenControlProps", + "kind": "interface", + "code": "interface ScriptMapLibreFullscreenControlProps {\n /** Position of the fullscreen control. */\n position?: MapLibre.ControlPosition\n /** Options passed to `new maplibregl.FullscreenControl()`. */\n options?: MapLibre.FullscreenControlOptions\n}" + }, + { + "name": "ScriptMapLibreFullscreenControlEvents", + "kind": "interface", + "code": "interface ScriptMapLibreFullscreenControlEvents {\n fullscreenstart: MapLibre.FullscreenControlEventType['fullscreenstart']\n fullscreenend: MapLibre.FullscreenControlEventType['fullscreenend']\n}" + }, { "name": "ScriptMapLibreGeoJsonProps", "kind": "interface", @@ -849,6 +864,16 @@ "kind": "interface", "code": "interface ScriptMapLibreGeoJsonEvents {\n error: Error\n click: MapLibre.MapLayerMouseEvent\n dblclick: MapLibre.MapLayerMouseEvent\n mouseenter: MapLibre.MapLayerMouseEvent\n mousemove: MapLibre.MapLayerMouseEvent\n mouseleave: MapLibre.MapLayerMouseEvent\n sourceready: { map: MapLibre.Map, sourceId: string }\n}" }, + { + "name": "ScriptMapLibreGeolocateControlProps", + "kind": "interface", + "code": "interface ScriptMapLibreGeolocateControlProps {\n /** Position of the geolocate control. */\n position?: MapLibre.ControlPosition\n /** Options passed to `new maplibregl.GeolocateControl()`. */\n options?: MapLibre.GeolocateControlOptions\n}" + }, + { + "name": "ScriptMapLibreGeolocateControlEvents", + "kind": "interface", + "code": "interface ScriptMapLibreGeolocateControlEvents {\n geolocate: MapLibre.GeolocateControlEventType['geolocate']\n error: MapLibre.GeolocateControlEventType['error']\n outofmaxbounds: MapLibre.GeolocateControlEventType['outofmaxbounds']\n trackuserlocationstart: MapLibre.GeolocateControlEventType['trackuserlocationstart']\n trackuserlocationend: MapLibre.GeolocateControlEventType['trackuserlocationend']\n userlocationfocus: MapLibre.GeolocateControlEventType['userlocationfocus']\n userlocationlostfocus: MapLibre.GeolocateControlEventType['userlocationlostfocus']\n unavailable: 'unsupported' | 'permission-denied'\n}" + }, { "name": "ScriptMapLibreMapProps", "kind": "interface", @@ -898,6 +923,11 @@ "name": "ScriptMapLibrePopupSlots", "kind": "interface", "code": "interface ScriptMapLibrePopupSlots {\n default?: () => any\n}" + }, + { + "name": "ScriptMapLibreScaleControlProps", + "kind": "interface", + "code": "interface ScriptMapLibreScaleControlProps {\n /** Position of the scale control. MapLibre places it bottom-left by default. */\n position?: MapLibre.ControlPosition\n /**\n * Options passed to `new maplibregl.ScaleControl()`.\n * `unit` also updates reactively through `setUnit()`.\n */\n options?: MapLibre.ScaleControlOptions\n}" } ], "matomo-analytics": [ @@ -4165,6 +4195,43 @@ "required": false } ], + "ScriptMapLibreAttributionControlProps": [ + { + "name": "position", + "type": "MapLibre.ControlPosition", + "required": false + }, + { + "name": "options", + "type": "MapLibre.AttributionControlOptions", + "required": false, + "description": "Options passed to `new maplibregl.AttributionControl()`. Source attribution from the style and tiles always shows. `customAttribution` only adds text." + } + ], + "ScriptMapLibreFullscreenControlProps": [ + { + "name": "position", + "type": "MapLibre.ControlPosition", + "required": false + }, + { + "name": "options", + "type": "MapLibre.FullscreenControlOptions", + "required": false + } + ], + "ScriptMapLibreFullscreenControlEvents": [ + { + "name": "fullscreenstart", + "type": "MapLibre.FullscreenControlEventType['fullscreenstart']", + "required": false + }, + { + "name": "fullscreenend", + "type": "MapLibre.FullscreenControlEventType['fullscreenend']", + "required": false + } + ], "ScriptMapLibreGeoJsonProps": [ { "name": "sourceId", @@ -4240,6 +4307,62 @@ "description": "The source and layers exist on the map. Fires after the first add and after every re-add, such as after a style swap. A re-add clears feature state, so restore it here." } ], + "ScriptMapLibreGeolocateControlProps": [ + { + "name": "position", + "type": "MapLibre.ControlPosition", + "required": false + }, + { + "name": "options", + "type": "MapLibre.GeolocateControlOptions", + "required": false + } + ], + "ScriptMapLibreGeolocateControlEvents": [ + { + "name": "geolocate", + "type": "MapLibre.GeolocateControlEventType['geolocate']", + "required": false + }, + { + "name": "error", + "type": "MapLibre.GeolocateControlEventType['error']", + "required": false, + "description": "The Geolocation API returned an error. `code` 1 means the user denied permission. MapLibre then disables the button." + }, + { + "name": "outofmaxbounds", + "type": "MapLibre.GeolocateControlEventType['outofmaxbounds']", + "required": false + }, + { + "name": "trackuserlocationstart", + "type": "MapLibre.GeolocateControlEventType['trackuserlocationstart']", + "required": false + }, + { + "name": "trackuserlocationend", + "type": "MapLibre.GeolocateControlEventType['trackuserlocationend']", + "required": false + }, + { + "name": "userlocationfocus", + "type": "MapLibre.GeolocateControlEventType['userlocationfocus']", + "required": false + }, + { + "name": "userlocationlostfocus", + "type": "MapLibre.GeolocateControlEventType['userlocationlostfocus']", + "required": false + }, + { + "name": "unavailable", + "type": "'unsupported' | 'permission-denied'", + "required": false, + "description": "Geolocation cannot work in this browser. `permission-denied` means the user blocked it before the control loaded. MapLibre disables the button and fires no `error` event in both cases." + } + ], "ScriptMapLibreMapProps": [ { "name": "trigger", @@ -4537,6 +4660,19 @@ "required": false } ], + "ScriptMapLibreScaleControlProps": [ + { + "name": "position", + "type": "MapLibre.ControlPosition", + "required": false + }, + { + "name": "options", + "type": "MapLibre.ScaleControlOptions", + "required": false, + "description": "Options passed to `new maplibregl.ScaleControl()`. `unit` also updates reactively through `setUnit()`." + } + ], "ScriptBlueskyEmbedProps": [ { "name": "postUrl", diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreAttributionControl.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreAttributionControl.vue new file mode 100644 index 000000000..53fb68291 --- /dev/null +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreAttributionControl.vue @@ -0,0 +1,64 @@ + diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreFullscreenControl.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreFullscreenControl.vue new file mode 100644 index 000000000..120f1e591 --- /dev/null +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreFullscreenControl.vue @@ -0,0 +1,36 @@ + diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreGeolocateControl.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreGeolocateControl.vue new file mode 100644 index 000000000..1d219f3c0 --- /dev/null +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreGeolocateControl.vue @@ -0,0 +1,67 @@ + diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreNavigationControl.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreNavigationControl.vue index 5e832e2d4..f7750c24f 100644 --- a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreNavigationControl.vue +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreNavigationControl.vue @@ -3,6 +3,12 @@ import type * as MapLibre from 'maplibre-gl' import type { ScriptMapLibreNavigationControlProps } from './types' import { useMapLibreResource } from './useMapLibreResource' +// Renders no DOM of its own. A render function that returns `null` gives a +// comment node on the server and the client. A comment-only template does not: +// a production build strips the comment, so the server renders nothing and +// hydration reports a mismatch. +defineOptions({ render: () => null }) + const props = defineProps() const control = useMapLibreResource({ @@ -19,7 +25,3 @@ const control = useMapLibreResource({ defineExpose({ control }) - - diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreScaleControl.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreScaleControl.vue new file mode 100644 index 000000000..3c3761145 --- /dev/null +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreScaleControl.vue @@ -0,0 +1,33 @@ + diff --git a/packages/script/src/runtime/components/MapLibre/types.ts b/packages/script/src/runtime/components/MapLibre/types.ts index b13373b3e..36f50273f 100644 --- a/packages/script/src/runtime/components/MapLibre/types.ts +++ b/packages/script/src/runtime/components/MapLibre/types.ts @@ -215,3 +215,70 @@ export interface ScriptMapLibreNavigationControlProps { /** Options passed to `new maplibregl.NavigationControl()`. */ options?: MapLibre.NavigationControlOptions } + +export interface ScriptMapLibreScaleControlProps { + /** Position of the scale control. MapLibre places it bottom-left by default. */ + position?: MapLibre.ControlPosition + /** + * Options passed to `new maplibregl.ScaleControl()`. + * `unit` also updates reactively through `setUnit()`. + */ + options?: MapLibre.ScaleControlOptions +} + +export interface ScriptMapLibreGeolocateControlProps { + /** Position of the geolocate control. */ + position?: MapLibre.ControlPosition + /** Options passed to `new maplibregl.GeolocateControl()`. */ + options?: MapLibre.GeolocateControlOptions +} + +export interface ScriptMapLibreGeolocateControlEmits { + /** The Geolocation API returned a position. */ + geolocate: [event: MapLibre.GeolocateControlEventType['geolocate']] + /** + * The Geolocation API returned an error. + * `code` 1 means the user denied permission. MapLibre then disables the button. + */ + error: [event: MapLibre.GeolocateControlEventType['error']] + /** The position is outside the map's `maxBounds`. */ + outofmaxbounds: [event: MapLibre.GeolocateControlEventType['outofmaxbounds']] + /** The control entered the active lock state. Needs `trackUserLocation`. */ + trackuserlocationstart: [event: MapLibre.GeolocateControlEventType['trackuserlocationstart']] + /** The control left the active lock state. Needs `trackUserLocation`. */ + trackuserlocationend: [event: MapLibre.GeolocateControlEventType['trackuserlocationend']] + /** The user clicked the button in the active lock state. */ + userlocationfocus: [event: MapLibre.GeolocateControlEventType['userlocationfocus']] + /** The user moved the map in the active lock state. */ + userlocationlostfocus: [event: MapLibre.GeolocateControlEventType['userlocationlostfocus']] + /** + * Geolocation cannot work in this browser. + * `permission-denied` means the user blocked it before the control loaded. + * MapLibre disables the button and fires no `error` event in both cases. + */ + unavailable: [reason: 'unsupported' | 'permission-denied'] +} + +export interface ScriptMapLibreFullscreenControlProps { + /** Position of the fullscreen control. */ + position?: MapLibre.ControlPosition + /** Options passed to `new maplibregl.FullscreenControl()`. */ + options?: MapLibre.FullscreenControlOptions +} + +export interface ScriptMapLibreFullscreenControlEmits { + /** The map entered fullscreen mode. */ + fullscreenstart: [event: MapLibre.FullscreenControlEventType['fullscreenstart']] + /** The map left fullscreen mode. */ + fullscreenend: [event: MapLibre.FullscreenControlEventType['fullscreenend']] +} + +export interface ScriptMapLibreAttributionControlProps { + /** Position of the attribution control. MapLibre places it bottom-right by default. */ + position?: MapLibre.ControlPosition + /** + * Options passed to `new maplibregl.AttributionControl()`. + * Source attribution from the style and tiles always shows. `customAttribution` only adds text. + */ + options?: MapLibre.AttributionControlOptions +} diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 5fe545723..61227ccab 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -60,10 +60,15 @@ export { MARKER_CLUSTERER_INJECTION_KEY } from './components/GoogleMaps/types' // MapLibre component types (re-exported for easy user access) export type { + ScriptMapLibreAttributionControlProps, + ScriptMapLibreFullscreenControlEmits, + ScriptMapLibreFullscreenControlProps, ScriptMapLibreGeoJsonEmits, ScriptMapLibreGeoJsonLayer, ScriptMapLibreGeoJsonProps, ScriptMapLibreGeoJsonResource, + ScriptMapLibreGeolocateControlEmits, + ScriptMapLibreGeolocateControlProps, ScriptMapLibreMapEmits, ScriptMapLibreMapExpose, ScriptMapLibreMapProps, @@ -73,6 +78,7 @@ export type { ScriptMapLibreNavigationControlProps, ScriptMapLibrePopupEmits, ScriptMapLibrePopupProps, + ScriptMapLibreScaleControlProps, } from './components/MapLibre/types' export type WarmupStrategy = false | 'preload' | 'preconnect' | 'dns-prefetch' diff --git a/playground/pages/third-parties/maplibre-controls.vue b/playground/pages/third-parties/maplibre-controls.vue new file mode 100644 index 000000000..cde843079 --- /dev/null +++ b/playground/pages/third-parties/maplibre-controls.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/scripts/generate-registry-types.ts b/scripts/generate-registry-types.ts index 20731af22..3ca7b8f14 100644 --- a/scripts/generate-registry-types.ts +++ b/scripts/generate-registry-types.ts @@ -746,6 +746,10 @@ const componentToSlug: Record = { ScriptMapLibrePopup: 'maplibre', ScriptMapLibreGeoJson: 'maplibre', ScriptMapLibreNavigationControl: 'maplibre', + ScriptMapLibreScaleControl: 'maplibre', + ScriptMapLibreGeolocateControl: 'maplibre', + ScriptMapLibreFullscreenControl: 'maplibre', + ScriptMapLibreAttributionControl: 'maplibre', ScriptCarbonAds: 'carbon-ads', ScriptCrisp: 'crisp', ScriptIntercom: 'intercom', diff --git a/test/e2e/maplibre-hydration.test.ts b/test/e2e/maplibre-hydration.test.ts new file mode 100644 index 000000000..a10917498 --- /dev/null +++ b/test/e2e/maplibre-hydration.test.ts @@ -0,0 +1,53 @@ +import { createResolver } from '@nuxt/kit' +import { $fetch, createPage, setup, url } from '@nuxt/test-utils/e2e' +import { describe, expect, it } from 'vitest' + +const { resolve } = createResolver(import.meta.url) + +/** + * A production compile strips template comments. A renderless component whose + * template holds only a comment then renders nothing on the server, while the + * client expects a comment node, so hydration reports a mismatch. + * + * `@nuxt/test-utils` builds inside the Vitest worker, where `NODE_ENV` is `test`. + * `@vue/compiler-core` picks its development build there, and its `comments` + * option defaults to `true`, so the bug cannot appear. Setting `comments: false` + * applies the production default. The first test proves it took effect. + */ +describe('maplibre hydration in a production build', { timeout: 120000 }, async () => { + await setup({ + rootDir: resolve('../fixtures/maplibre'), + browser: true, + nuxtConfig: { + vue: { compilerOptions: { comments: false } }, + }, + }) + + it('compiles the fixture in production mode', async () => { + // Guards the guard: a development compile keeps this template comment. + const html = await $fetch('/controls') + expect(html).not.toContain('production-build-probe') + }) + + it('hydrates every control component without a mismatch', async () => { + const page = await createPage() + const messages: string[] = [] + page.on('console', message => messages.push(`${message.type()}: ${message.text()}`)) + page.on('pageerror', error => messages.push(`pageerror: ${error.message}`)) + await page.goto(url('/controls'), { waitUntil: 'hydration' }) + await page.waitForFunction(() => (window as any).__ready === true, undefined, { timeout: 20000 }) + + expect(messages.filter(message => /hydrat|mismatch/i.test(message))).toEqual([]) + + const controls = await page.evaluate(() => ({ + navigation: document.querySelectorAll('.maplibregl-ctrl-top-right .maplibregl-ctrl-zoom-in').length, + scale: document.querySelectorAll('.maplibregl-ctrl-bottom-left .maplibregl-ctrl-scale').length, + geolocate: document.querySelectorAll('.maplibregl-ctrl-top-left .maplibregl-ctrl-geolocate').length, + fullscreen: document.querySelectorAll('.maplibregl-ctrl-top-left .maplibregl-ctrl-fullscreen').length, + attribution: document.querySelectorAll('.maplibregl-ctrl-attrib').length, + attributionCorner: document.querySelectorAll('.maplibregl-ctrl-bottom-left .maplibregl-ctrl-attrib').length, + })) + expect(controls).toEqual({ navigation: 1, scale: 1, geolocate: 1, fullscreen: 1, attribution: 1, attributionCorner: 1 }) + await page.close() + }) +}) diff --git a/test/fixtures/maplibre/pages/controls.vue b/test/fixtures/maplibre/pages/controls.vue new file mode 100644 index 000000000..392bf6c23 --- /dev/null +++ b/test/fixtures/maplibre/pages/controls.vue @@ -0,0 +1,31 @@ + + + diff --git a/test/nuxt-runtime/maplibre-controls.nuxt.test.ts b/test/nuxt-runtime/maplibre-controls.nuxt.test.ts new file mode 100644 index 000000000..a4f455e1d --- /dev/null +++ b/test/nuxt-runtime/maplibre-controls.nuxt.test.ts @@ -0,0 +1,346 @@ +import { mount } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { nextTick, shallowRef } from 'vue' +import ScriptMapLibreAttributionControl from '../../packages/script/src/runtime/components/MapLibre/ScriptMapLibreAttributionControl.vue' +import ScriptMapLibreFullscreenControl from '../../packages/script/src/runtime/components/MapLibre/ScriptMapLibreFullscreenControl.vue' +import ScriptMapLibreGeolocateControl from '../../packages/script/src/runtime/components/MapLibre/ScriptMapLibreGeolocateControl.vue' +import ScriptMapLibreScaleControl from '../../packages/script/src/runtime/components/MapLibre/ScriptMapLibreScaleControl.vue' +import { MAPLIBRE_MAP_INJECTION_KEY } from '../../packages/script/src/runtime/components/MapLibre/useMapLibreResource' + +interface FakeControl { + kind: string + options: unknown + on: (name: string, listener: (event: unknown) => void) => void + off: (name: string, listener: (event: unknown) => void) => void + fire: (name: string, event: unknown) => void + listenerCount: () => number + setUnit: ReturnType +} + +/** A control that stores its listeners, so a test can fire MapLibre events at it. */ +function initControl(target: object, kind: string, options: unknown): FakeControl { + const listeners = new Map void>>() + return Object.assign(target, { + kind, + options, + on: (name: string, listener: (event: unknown) => void) => { + listeners.set(name, (listeners.get(name) ?? new Set()).add(listener)) + }, + off: (name: string, listener: (event: unknown) => void) => { + listeners.get(name)?.delete(listener) + }, + fire: (name: string, event: unknown) => listeners.get(name)?.forEach(listener => listener(event)), + listenerCount: () => [...listeners.values()].reduce((total, set) => total + set.size, 0), + setUnit: vi.fn(), + }) +} + +/** + * The control list follows MapLibre: `addControl` appends, `removeControl` + * splices, `hasControl` reads the list, and `remove()` empties it. + */ +function createMap() { + const positions = new Map() + const map = { + _controls: [] as FakeControl[], + _removed: false, + addControl: vi.fn((control: FakeControl, position?: string) => { + map._controls.push(control) + positions.set(control, position) + return map + }), + removeControl: vi.fn((control: FakeControl) => { + map._controls = map._controls.filter(existing => existing !== control) + return map + }), + hasControl: (control: FakeControl) => map._controls.includes(control), + remove: () => { + map._controls = [] + map._removed = true + }, + positionOf: (control: unknown) => positions.get(control), + } + return map +} + +function createMapLibre() { + function ScaleControl(this: object, options: unknown) { + initControl(this, 'scale', options) + } + function FullscreenControl(this: object, options: unknown) { + initControl(this, 'fullscreen', options) + } + function GeolocateControl(this: object, options: unknown) { + initControl(this, 'geolocate', options) + } + function AttributionControl(this: object, options: unknown) { + initControl(this, 'attribution', options) + } + return { ScaleControl, FullscreenControl, GeolocateControl, AttributionControl } +} + +function provideMap(maplibre: unknown, map: unknown) { + return { + provide: { + [MAPLIBRE_MAP_INJECTION_KEY as symbol]: { + map: shallowRef(map), + maplibre: shallowRef(maplibre), + }, + }, + } +} + +function controlsOf(map: ReturnType, kind: string) { + return map._controls.filter(control => control.kind === kind) +} + +function stubGeolocation(state: PermissionState | 'no-api' | 'rejects') { + if (state === 'no-api') { + vi.stubGlobal('navigator', {}) + return + } + vi.stubGlobal('navigator', { + geolocation: {}, + permissions: { + query: state === 'rejects' + ? () => Promise.reject(new TypeError('geolocation is not a valid permission name')) + : () => Promise.resolve({ state }), + }, + }) +} + +async function flush() { + await nextTick() + await new Promise(resolve => setTimeout(resolve, 0)) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('mapLibre scale control', () => { + it('waits for the map, then adds the control once', async () => { + const maplibre = createMapLibre() + const map = createMap() + const mapRef = shallowRef() + mount(ScriptMapLibreScaleControl, { + props: { position: 'bottom-left', options: { unit: 'metric' } }, + global: { provide: { [MAPLIBRE_MAP_INJECTION_KEY as symbol]: { map: mapRef, maplibre: shallowRef(maplibre) } } }, + }) + await nextTick() + expect(map.addControl).not.toHaveBeenCalled() + + mapRef.value = map + await nextTick() + const [scale] = controlsOf(map, 'scale') + expect(controlsOf(map, 'scale')).toHaveLength(1) + expect(scale!.options).toEqual({ unit: 'metric' }) + expect(map.positionOf(scale)).toBe('bottom-left') + }) + + it('updates the unit in place and removes the control on unmount', async () => { + const map = createMap() + const wrapper = mount(ScriptMapLibreScaleControl, { + props: { options: { unit: 'metric' } }, + global: provideMap(createMapLibre(), map), + }) + await nextTick() + const [scale] = controlsOf(map, 'scale') + + await wrapper.setProps({ options: { unit: 'imperial' } }) + expect(scale!.setUnit).toHaveBeenCalledWith('imperial') + expect(map.addControl).toHaveBeenCalledOnce() + + wrapper.unmount() + expect(map._controls).toEqual([]) + }) +}) + +describe('mapLibre fullscreen control', () => { + it('re-emits fullscreen events and detaches on unmount', async () => { + const map = createMap() + const wrapper = mount(ScriptMapLibreFullscreenControl, { + props: { position: 'top-left', options: { pseudo: true } }, + global: provideMap(createMapLibre(), map), + }) + await nextTick() + const [fullscreen] = controlsOf(map, 'fullscreen') + expect(map.positionOf(fullscreen)).toBe('top-left') + expect(fullscreen!.options).toEqual({ pseudo: true }) + + fullscreen!.fire('fullscreenstart', { type: 'fullscreenstart' }) + fullscreen!.fire('fullscreenend', { type: 'fullscreenend' }) + expect(wrapper.emitted('fullscreenstart')).toEqual([[{ type: 'fullscreenstart' }]]) + expect(wrapper.emitted('fullscreenend')).toEqual([[{ type: 'fullscreenend' }]]) + + wrapper.unmount() + expect(map._controls).toEqual([]) + expect(fullscreen!.listenerCount()).toBe(0) + }) +}) + +describe('mapLibre geolocate control', () => { + it('re-emits position and permission errors from the control', async () => { + stubGeolocation('prompt') + const map = createMap() + const wrapper = mount(ScriptMapLibreGeolocateControl, { + props: { position: 'top-right', options: { trackUserLocation: true } }, + global: provideMap(createMapLibre(), map), + }) + await flush() + const [geolocate] = controlsOf(map, 'geolocate') + expect(map.positionOf(geolocate)).toBe('top-right') + + const position = { type: 'geolocate', coords: { latitude: -42.88, longitude: 147.33 } } + const denied = { type: 'error', code: 1, message: 'User denied Geolocation' } + geolocate!.fire('geolocate', position) + geolocate!.fire('error', denied) + geolocate!.fire('trackuserlocationstart', { type: 'trackuserlocationstart' }) + + expect(wrapper.emitted('geolocate')).toEqual([[position]]) + expect(wrapper.emitted('error')).toEqual([[denied]]) + expect(wrapper.emitted('trackuserlocationstart')).toHaveLength(1) + expect(wrapper.emitted('unavailable')).toBeUndefined() + + wrapper.unmount() + expect(map._controls).toEqual([]) + expect(geolocate!.listenerCount()).toBe(0) + }) + + it.each([ + ['denied', 'permission-denied'], + ['no-api', 'unsupported'], + ] as const)('emits unavailable when the permission state is %s', async (state, reason) => { + stubGeolocation(state) + const wrapper = mount(ScriptMapLibreGeolocateControl, { + global: provideMap(createMapLibre(), createMap()), + }) + await flush() + expect(wrapper.emitted('unavailable')).toEqual([[reason]]) + }) + + it('trusts the geolocation API when the permission query rejects', async () => { + stubGeolocation('rejects') + const wrapper = mount(ScriptMapLibreGeolocateControl, { + global: provideMap(createMapLibre(), createMap()), + }) + await flush() + expect(wrapper.emitted('unavailable')).toBeUndefined() + }) + + it('does not emit unavailable after unmount', async () => { + stubGeolocation('denied') + const wrapper = mount(ScriptMapLibreGeolocateControl, { + global: provideMap(createMapLibre(), createMap()), + }) + await nextTick() + wrapper.unmount() + await flush() + expect(wrapper.emitted('unavailable')).toBeUndefined() + }) +}) + +describe('mapLibre attribution control', () => { + function mapWithDefaultAttribution(maplibre: ReturnType, options: Record = { compact: true }) { + const map = createMap() + const builtIn = new (maplibre.AttributionControl as any)(options) as FakeControl + map.addControl(builtIn) + map.addControl.mockClear() + return { map, builtIn } + } + + it('replaces the map default so attribution shows once, then restores it', async () => { + const maplibre = createMapLibre() + const { map, builtIn } = mapWithDefaultAttribution(maplibre) + const wrapper = mount(ScriptMapLibreAttributionControl, { + props: { position: 'bottom-left', options: { compact: false } }, + global: provideMap(maplibre, map), + }) + await nextTick() + + const mounted = controlsOf(map, 'attribution') + expect(mounted).toHaveLength(1) + expect(mounted[0]).not.toBe(builtIn) + expect(mounted[0]!.options).toMatchObject({ compact: false }) + expect(map.positionOf(mounted[0])).toBe('bottom-left') + + wrapper.unmount() + expect(controlsOf(map, 'attribution')).toEqual([builtIn]) + }) + + it('adds and removes its own control when the map has no default', async () => { + const map = createMap() + const wrapper = mount(ScriptMapLibreAttributionControl, { + global: provideMap(createMapLibre(), map), + }) + await nextTick() + expect(controlsOf(map, 'attribution')).toHaveLength(1) + + wrapper.unmount() + expect(map._controls).toEqual([]) + }) + + it('restores the map default when adding its own control throws', async () => { + const maplibre = createMapLibre() + const { map, builtIn } = mapWithDefaultAttribution(maplibre) + const failure = new Error('onAdd failed') + map.addControl.mockImplementationOnce(() => { + throw failure + }) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + mount(ScriptMapLibreAttributionControl, { + global: provideMap(maplibre, map), + }) + await nextTick() + + expect(controlsOf(map, 'attribution')).toEqual([builtIn]) + expect(consoleError).toHaveBeenCalledWith('[nuxt-scripts] MapLibre resource creation failed:', failure) + consoleError.mockRestore() + }) + + it('keeps credits the map default configured unless the component sets its own', async () => { + const maplibre = createMapLibre() + const { map } = mapWithDefaultAttribution(maplibre, { compact: true, customAttribution: 'Data: Hobart City Council' }) + const inheriting = mount(ScriptMapLibreAttributionControl, { + props: { options: { compact: false } }, + global: provideMap(maplibre, map), + }) + await nextTick() + expect(controlsOf(map, 'attribution')[0]!.options).toEqual({ compact: false, customAttribution: 'Data: Hobart City Council' }) + inheriting.unmount() + + const overriding = mount(ScriptMapLibreAttributionControl, { + props: { options: { customAttribution: 'Data: Tasmania' } }, + global: provideMap(maplibre, map), + }) + await nextTick() + expect(controlsOf(map, 'attribution')[0]!.options).toEqual({ compact: true, customAttribution: 'Data: Tasmania' }) + overriding.unmount() + }) + + it('restores the map default after consumer code removed the component control', async () => { + const maplibre = createMapLibre() + const { map, builtIn } = mapWithDefaultAttribution(maplibre) + const wrapper = mount(ScriptMapLibreAttributionControl, { + global: provideMap(maplibre, map), + }) + await nextTick() + + map.removeControl(controlsOf(map, 'attribution')[0]!) + wrapper.unmount() + expect(controlsOf(map, 'attribution')).toEqual([builtIn]) + }) + + it('does not restore onto a map that was already removed', async () => { + const maplibre = createMapLibre() + const { map } = mapWithDefaultAttribution(maplibre) + const wrapper = mount(ScriptMapLibreAttributionControl, { + global: provideMap(maplibre, map), + }) + await nextTick() + + map.remove() + wrapper.unmount() + expect(map._controls).toEqual([]) + }) +}) diff --git a/test/types/maplibre-controls.test-d.ts b/test/types/maplibre-controls.test-d.ts new file mode 100644 index 000000000..a28342a42 --- /dev/null +++ b/test/types/maplibre-controls.test-d.ts @@ -0,0 +1,46 @@ +import type { + ScriptMapLibreAttributionControlProps, + ScriptMapLibreFullscreenControlEmits, + ScriptMapLibreFullscreenControlProps, + ScriptMapLibreGeolocateControlEmits, + ScriptMapLibreGeolocateControlProps, + ScriptMapLibreScaleControlProps, +} from '@nuxt/scripts' +import { describe, expectTypeOf, it } from 'vitest' + +/** + * PC-15: the control components need public prop and emit types, so a consumer + * can type options and handlers without a deep import. These come from the + * package entry only, so a missing export fails this file. + */ +describe('mapLibre control types', () => { + it('types the control options from the MapLibre constructors', () => { + const scale: ScriptMapLibreScaleControlProps = { position: 'bottom-left', options: { unit: 'metric', maxWidth: 120 } } + const geolocate: ScriptMapLibreGeolocateControlProps = { options: { trackUserLocation: true, positionOptions: { enableHighAccuracy: true } } } + const fullscreen: ScriptMapLibreFullscreenControlProps = { position: 'top-left', options: { pseudo: true } } + const attribution: ScriptMapLibreAttributionControlProps = { options: { compact: false, customAttribution: ['Data: Tasmania'] } } + + // @ts-expect-error ScaleControl accepts only imperial, metric or nautical + const badUnit: ScriptMapLibreScaleControlProps = { options: { unit: 'furlong' } } + // @ts-expect-error a control position names a map corner + const badPosition: ScriptMapLibreFullscreenControlProps = { position: 'middle' } + + expectTypeOf(scale).not.toBeAny() + expectTypeOf(geolocate).not.toBeAny() + expectTypeOf(fullscreen).not.toBeAny() + expectTypeOf(attribution).not.toBeAny() + expectTypeOf(badUnit).not.toBeAny() + expectTypeOf(badPosition).not.toBeAny() + }) + + it('types the geolocate event payloads', () => { + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf<'unsupported' | 'permission-denied'>() + }) + + it('types the fullscreen event payloads', () => { + expectTypeOf().not.toBeAny() + expectTypeOf().toEqualTypeOf<'fullscreenstart' | 'fullscreenend'>() + }) +})