diff --git a/docs/content/scripts/maplibre/2.api/10.attribution-control.md b/docs/content/scripts/maplibre/2.api/10.attribution-control.md index 45dd17c2f..daf621aed 100644 --- a/docs/content/scripts/maplibre/2.api/10.attribution-control.md +++ b/docs/content/scripts/maplibre/2.api/10.attribution-control.md @@ -26,10 +26,12 @@ MapLibre adds an attribution control to every map by default. Two controls would - 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. +When no other attribution control is present, the map 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. +The component replaces only the control that ``{lang="html"} adds. If your code adds an attribution control with `map.addControl()`{lang="ts"}, that control stays on the map. + Use one ``{lang="html"} per map. The map's control returns to its default position, bottom-right, when the component unmounts. ## Attribution text diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreAttributionControl.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreAttributionControl.vue index 95b3d54e3..6816fc4b6 100644 --- a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreAttributionControl.vue +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreAttributionControl.vue @@ -1,7 +1,8 @@ + + diff --git a/test/fixtures/maplibre/pages/logo.vue b/test/fixtures/maplibre/pages/logo.vue new file mode 100644 index 000000000..2eb733caa --- /dev/null +++ b/test/fixtures/maplibre/pages/logo.vue @@ -0,0 +1,24 @@ + + + diff --git a/test/nuxt-runtime/maplibre-controls.nuxt.test.ts b/test/nuxt-runtime/maplibre-controls.nuxt.test.ts index 4c44e48ad..b6db79299 100644 --- a/test/nuxt-runtime/maplibre-controls.nuxt.test.ts +++ b/test/nuxt-runtime/maplibre-controls.nuxt.test.ts @@ -37,27 +37,38 @@ function initControl(target: object, kind: string, options: unknown): FakeContro /** * The control list follows MapLibre: `addControl` appends, `removeControl` - * splices, `hasControl` reads the list, and `remove()` empties it. + * splices, `hasControl` reads the list, and `remove()` empties it and fires + * `remove`. The list is private to the fake, so a component can reach it only + * through the public MapLibre API. */ function createMap() { const positions = new Map() + const listeners = new Map void>>() + let controls: FakeControl[] = [] const map = { - _controls: [] as FakeControl[], - _removed: false, addControl: vi.fn((control: FakeControl, position?: string) => { - map._controls.push(control) + controls.push(control) positions.set(control, position) return map }), removeControl: vi.fn((control: FakeControl) => { - map._controls = map._controls.filter(existing => existing !== control) + controls = controls.filter(existing => existing !== control) return map }), - hasControl: (control: FakeControl) => map._controls.includes(control), + hasControl: (control: FakeControl) => controls.includes(control), + on: (name: string, listener: () => void) => { + listeners.set(name, (listeners.get(name) ?? new Set()).add(listener)) + return map + }, + off: (name: string, listener: () => void) => { + listeners.get(name)?.delete(listener) + return map + }, remove: () => { - map._controls = [] - map._removed = true + controls = [] + listeners.get('remove')?.forEach(listener => listener()) }, + controls: () => [...controls], positionOf: (control: unknown) => positions.get(control), } return map @@ -79,19 +90,20 @@ function createMapLibre() { return { ScaleControl, FullscreenControl, GeolocateControl, AttributionControl } } -function provideMap(maplibre: unknown, map: unknown) { +function provideMap(maplibre: unknown, map: unknown, defaultAttributionControl?: unknown) { return { provide: { [MAPLIBRE_MAP_INJECTION_KEY as symbol]: { map: shallowRef(map), maplibre: shallowRef(maplibre), + defaultAttributionControl: shallowRef(defaultAttributionControl), }, }, } } function controlsOf(map: ReturnType, kind: string) { - return map._controls.filter(control => control.kind === kind) + return map.controls().filter(control => control.kind === kind) } function stubGeolocation(state: PermissionState | 'no-api' | 'rejects') { @@ -125,7 +137,7 @@ describe('mapLibre scale control', () => { 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) } } }, + global: { provide: { [MAPLIBRE_MAP_INJECTION_KEY as symbol]: { map: mapRef, maplibre: shallowRef(maplibre), defaultAttributionControl: shallowRef() } } }, }) await nextTick() expect(map.addControl).not.toHaveBeenCalled() @@ -152,7 +164,7 @@ describe('mapLibre scale control', () => { expect(map.addControl).toHaveBeenCalledOnce() wrapper.unmount() - expect(map._controls).toEqual([]) + expect(map.controls()).toEqual([]) }) }) @@ -174,7 +186,7 @@ describe('mapLibre fullscreen control', () => { expect(wrapper.emitted('fullscreenend')).toEqual([[{ type: 'fullscreenend' }]]) wrapper.unmount() - expect(map._controls).toEqual([]) + expect(map.controls()).toEqual([]) expect(fullscreen!.listenerCount()).toBe(0) }) }) @@ -203,7 +215,7 @@ describe('mapLibre geolocate control', () => { expect(wrapper.emitted('unavailable')).toBeUndefined() wrapper.unmount() - expect(map._controls).toEqual([]) + expect(map.controls()).toEqual([]) expect(geolocate!.listenerCount()).toBe(0) }) @@ -241,20 +253,21 @@ describe('mapLibre geolocate control', () => { }) describe('mapLibre attribution control', () => { + /** Mirrors ``: the map adds its default control and shares it through the context. */ 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 } + return { map, builtIn, global: provideMap(maplibre, map, builtIn) } } it('replaces the map default so attribution shows once, then restores it', async () => { const maplibre = createMapLibre() - const { map, builtIn } = mapWithDefaultAttribution(maplibre) + const { map, builtIn, global } = mapWithDefaultAttribution(maplibre) const wrapper = mount(ScriptMapLibreAttributionControl, { props: { position: 'bottom-left', options: { compact: false } }, - global: provideMap(maplibre, map), + global, }) await nextTick() @@ -277,20 +290,18 @@ describe('mapLibre attribution control', () => { expect(controlsOf(map, 'attribution')).toHaveLength(1) wrapper.unmount() - expect(map._controls).toEqual([]) + 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 { map, builtIn, global } = 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), - }) + mount(ScriptMapLibreAttributionControl, { global }) await nextTick() expect(controlsOf(map, 'attribution')).toEqual([builtIn]) @@ -300,46 +311,42 @@ describe('mapLibre attribution control', () => { 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 { map, global } = mapWithDefaultAttribution(maplibre, { compact: true, customAttribution: 'Data: Hobart City Council' }) const inheriting = mount(ScriptMapLibreAttributionControl, { props: { options: { compact: false } }, - global: provideMap(maplibre, map), + global, }) await nextTick() - expect(controlsOf(map, 'attribution')[0]!.options).toEqual({ compact: false, customAttribution: ['Data: Hobart City Council'] }) + 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), + global, }) await nextTick() expect(controlsOf(map, 'attribution')[0]!.options).toEqual({ compact: true, customAttribution: 'Data: Tasmania' }) overriding.unmount() }) - it('keeps the credits of every replaced attribution control', async () => { + it('never removes an attribution control that consumer code added', async () => { const maplibre = createMapLibre() - const { map } = mapWithDefaultAttribution(maplibre, { compact: true, customAttribution: 'MapLibre' }) - map.addControl(new (maplibre.AttributionControl as any)({ customAttribution: ['Data: Hobart City Council', 'MapLibre'] })) - const wrapper = mount(ScriptMapLibreAttributionControl, { - global: provideMap(maplibre, map), - }) + const { map, builtIn, global } = mapWithDefaultAttribution(maplibre) + const added = new (maplibre.AttributionControl as any)({ customAttribution: 'Data: Hobart City Council' }) as FakeControl + map.addControl(added) + const wrapper = mount(ScriptMapLibreAttributionControl, { global }) await nextTick() - const [mounted] = controlsOf(map, 'attribution') - expect(controlsOf(map, 'attribution')).toHaveLength(1) - expect((mounted!.options as { customAttribution: unknown }).customAttribution).toEqual(['MapLibre', 'Data: Hobart City Council']) + expect(controlsOf(map, 'attribution')).toContain(added) + expect(controlsOf(map, 'attribution')).not.toContain(builtIn) wrapper.unmount() - expect(controlsOf(map, 'attribution')).toHaveLength(2) + expect(controlsOf(map, 'attribution')).toEqual([added, builtIn]) }) 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), - }) + const { map, builtIn, global } = mapWithDefaultAttribution(maplibre) + const wrapper = mount(ScriptMapLibreAttributionControl, { global }) await nextTick() map.removeControl(controlsOf(map, 'attribution')[0]!) @@ -347,16 +354,25 @@ describe('mapLibre attribution control', () => { expect(controlsOf(map, 'attribution')).toEqual([builtIn]) }) + it('does not add back a default that consumer code removed before mount', async () => { + const maplibre = createMapLibre() + const { map, builtIn, global } = mapWithDefaultAttribution(maplibre) + map.removeControl(builtIn) + const wrapper = mount(ScriptMapLibreAttributionControl, { global }) + await nextTick() + + wrapper.unmount() + expect(map.controls()).toEqual([]) + }) + 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), - }) + const { map, global } = mapWithDefaultAttribution(maplibre) + const wrapper = mount(ScriptMapLibreAttributionControl, { global }) await nextTick() map.remove() wrapper.unmount() - expect(map._controls).toEqual([]) + expect(map.controls()).toEqual([]) }) }) diff --git a/test/nuxt-runtime/maplibre-map.nuxt.test.ts b/test/nuxt-runtime/maplibre-map.nuxt.test.ts index 10cecea9b..ca6d8007f 100644 --- a/test/nuxt-runtime/maplibre-map.nuxt.test.ts +++ b/test/nuxt-runtime/maplibre-map.nuxt.test.ts @@ -52,6 +52,7 @@ function createMapLibreMock() { setStyle: vi.fn(() => map), resize: vi.fn(() => map), remove: vi.fn(), + addControl: vi.fn(() => map), getCanvas: vi.fn(() => document.createElement('canvas')), keyboard: { isEnabled: vi.fn(() => true) }, } @@ -65,6 +66,9 @@ function createMapLibreMock() { const maplibregl = { Map: vi.fn(MapConstructor), + AttributionControl: vi.fn(function (this: { options: unknown }, options: unknown) { + this.options = options + }), LngLat: { convert: vi.fn(value => ({ lng: value[0], lat: value[1] })) }, } return { @@ -262,4 +266,27 @@ describe('scriptMapLibreMap', () => { expect(wrapper.emitted('error')?.[0]).toEqual([initializationFailure]) expect(wrapper.emitted('ready')).toBeUndefined() }) + + it.each([ + ['omitted', undefined, [undefined]], + ['options', { compact: false, customAttribution: 'Data: Hobart City Council' }, [{ compact: false, customAttribution: 'Data: Hobart City Council' }]], + ['false', false, undefined], + ] as const)('adds the default attribution control itself when attributionControl is %s', async (_, attributionControl, expected) => { + const mocks = createMapLibreMock() + mount(ScriptMapLibreMap, { + props: { + mapStyle: 'https://demotiles.maplibre.org/style.json', + center: [0, 0], + options: attributionControl === undefined ? {} : { attributionControl }, + }, + }) + await nextTick() + scriptState.callbacks[0]!({ maplibregl: mocks.maplibregl }) + await nextTick() + + // MapLibre must not add a second control of its own. + expect(mocks.maplibregl.Map).toHaveBeenCalledWith(expect.objectContaining({ attributionControl: false })) + const controls = mocks.map.addControl.mock.calls.map(([control]: [{ options: unknown }]) => control.options) + expect(controls).toEqual(expected ?? []) + }) }) diff --git a/test/unit/maplibre-lifecycle.test.ts b/test/unit/maplibre-lifecycle.test.ts index bf4d69aea..65c14a6d5 100644 --- a/test/unit/maplibre-lifecycle.test.ts +++ b/test/unit/maplibre-lifecycle.test.ts @@ -12,7 +12,7 @@ function createProvider(immediate = true, clearBeforeChildren = false) { const Provider = defineComponent({ setup(_, { slots }) { - provide(MAPLIBRE_MAP_INJECTION_KEY, { map, maplibre }) + provide(MAPLIBRE_MAP_INJECTION_KEY, { map, maplibre, defaultAttributionControl: shallowRef() }) if (clearBeforeChildren) { onBeforeUnmount(() => { map.value = undefined