From 2b30b9b0deda8d29aac7de58c13989d4a860712e Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Sat, 1 Aug 2026 16:19:03 +0200 Subject: [PATCH 01/10] Spike: declarative dynamic-DOM-native mapbox family Re-architect the @studiometa/ui-mapbox family so MapboxMap no longer declares its children. Children register independently, resolve their parent map via $closest on mount and self-gate on map readiness through AbstractMapboxMapChild.whenMapReady, injecting on ready and cleaning up in destroyed() against a cached map ref. This makes every child Fetch/appendChild dynamic-DOM-native (self-inject on insertion, self-clean on removal). Dissolve StoreLocator/StoreLocatorItem into declarative MapboxCluster + MapboxClusterItem: rendered items are simultaneously the list and the GeoJSON source, one registry driving both. Add registerMapboxComponents() convenience helper. Spike baseline (pre-hardening): build + lint + 102 tests green. Known open blockers to address before shipping: unguarded throws can deadlock the global SmartQueue (B1), teardown against a removed map throws (B2), Fetch-swap dup-id on Source/Layer (B3), and no-retry map resolution for isolated dynamic insertion (M1). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01NeZwHwo3d9rYsCxJUQqTep --- .../MapboxMap/AbstractMapboxMapChild.spec.ts | 101 +++ .../tests/MapboxMap/MapboxCluster.spec.ts | 387 +++++----- .../MapboxMap/MapboxFullscreenControl.spec.ts | 2 +- .../tests/MapboxMap/MapboxGeocoder.spec.ts | 2 +- .../MapboxMap/MapboxGeocoder.teardown.spec.ts | 2 +- .../MapboxMap/MapboxGeolocateControl.spec.ts | 2 +- packages/tests/MapboxMap/MapboxImage.spec.ts | 2 +- packages/tests/MapboxMap/MapboxImages.spec.ts | 4 +- packages/tests/MapboxMap/MapboxLayer.spec.ts | 2 +- packages/tests/MapboxMap/MapboxMarker.spec.ts | 4 +- .../MapboxMap/MapboxNavigationControl.spec.ts | 2 +- packages/tests/MapboxMap/MapboxPopup.spec.ts | 4 +- packages/tests/MapboxMap/MapboxSource.spec.ts | 2 +- packages/tests/MapboxMap/StoreLocator.spec.ts | 727 ------------------ .../tests/MapboxMap/StoreLocatorItem.spec.ts | 60 -- packages/tests/MapboxMap/exports.spec.ts | 4 +- .../tests/MapboxMap/subpath-exports.spec.ts | 21 +- packages/ui-mapbox/AbstractMapboxControl.ts | 7 +- packages/ui-mapbox/AbstractMapboxMapChild.ts | 94 ++- packages/ui-mapbox/MapboxCluster.ts | 627 +++++++++++---- packages/ui-mapbox/MapboxClusterItem.ts | 160 ++++ packages/ui-mapbox/MapboxGeocoder.ts | 50 +- packages/ui-mapbox/MapboxImage.ts | 39 +- packages/ui-mapbox/MapboxImages.ts | 60 +- packages/ui-mapbox/MapboxLayer.ts | 48 +- packages/ui-mapbox/MapboxMap.ts | 36 +- packages/ui-mapbox/MapboxMarker.ts | 19 +- packages/ui-mapbox/MapboxPopup.ts | 32 +- packages/ui-mapbox/MapboxSource.ts | 40 +- packages/ui-mapbox/StoreLocator.ts | 504 ------------ packages/ui-mapbox/StoreLocatorItem.ts | 144 ---- packages/ui-mapbox/index.ts | 4 +- .../ui-mapbox/registerMapboxComponents.ts | 46 ++ packages/ui-mapbox/utils.ts | 30 - 34 files changed, 1256 insertions(+), 2012 deletions(-) create mode 100644 packages/tests/MapboxMap/AbstractMapboxMapChild.spec.ts delete mode 100644 packages/tests/MapboxMap/StoreLocator.spec.ts delete mode 100644 packages/tests/MapboxMap/StoreLocatorItem.spec.ts create mode 100644 packages/ui-mapbox/MapboxClusterItem.ts delete mode 100644 packages/ui-mapbox/StoreLocator.ts delete mode 100644 packages/ui-mapbox/StoreLocatorItem.ts create mode 100644 packages/ui-mapbox/registerMapboxComponents.ts diff --git a/packages/tests/MapboxMap/AbstractMapboxMapChild.spec.ts b/packages/tests/MapboxMap/AbstractMapboxMapChild.spec.ts new file mode 100644 index 00000000..b5cfb8ba --- /dev/null +++ b/packages/tests/MapboxMap/AbstractMapboxMapChild.spec.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi } from 'vitest'; +import { h } from '#test-utils'; +import { MockMap } from './mock-mapbox-gl.js'; +import { MapboxMarker } from '@studiometa/ui-mapbox'; + +/** + * A minimal `MapboxMap` stand-in whose `map-load` can be fired on demand, used to + * exercise `AbstractMapboxMapChild.whenMapReady` through a concrete child. + */ +function createDeferredMap() { + const mockMap = new MockMap(); + const handlers: Function[] = []; + + const mapboxMap = { + map: mockMap, + isLoaded: false, + $options: { accessToken: 'token' }, + $on(event: string, cb: Function) { + if (event === 'map-load') { + handlers.push(cb); + } + return () => { + const index = handlers.indexOf(cb); + if (index > -1) handlers.splice(index, 1); + }; + }, + fireLoad() { + this.isLoaded = true; + handlers.slice().forEach((cb) => cb()); + }, + get pending() { + return handlers.length; + }, + }; + + return { mapboxMap, mockMap }; +} + +function createMarker(mapboxMap: unknown) { + const el = h('div', { + 'data-component': 'MapboxMarker', + 'data-option-lng-lat': '[2.35, 48.85]', + }); + const instance = new MapboxMarker(el); + instance.$closest = vi.fn((query: string) => + query === 'MapboxMap' ? (mapboxMap as any) : undefined, + ); + return instance; +} + +describe('AbstractMapboxMapChild.whenMapReady', () => { + it('should run the callback synchronously when the map is already loaded', async () => { + const { mapboxMap, mockMap } = createDeferredMap(); + mapboxMap.isLoaded = true; + const instance = createMarker(mapboxMap); + + vi.useFakeTimers(); + instance.$mount(); + await vi.advanceTimersByTimeAsync(100); + vi.useRealTimers(); + + // The marker injected itself against the ready map right away. + expect((instance.marker as any).addTo).toHaveBeenCalledWith(mockMap); + }); + + it('should defer the callback until map-load fires when the map is not loaded yet', async () => { + const { mapboxMap, mockMap } = createDeferredMap(); + const instance = createMarker(mapboxMap); + + vi.useFakeTimers(); + instance.$mount(); + await vi.advanceTimersByTimeAsync(100); + vi.useRealTimers(); + + // Nothing injected yet: the map has not loaded. + expect((instance.marker as any).addTo).not.toHaveBeenCalled(); + expect(mapboxMap.pending).toBe(1); + + mapboxMap.fireLoad(); + expect((instance.marker as any).addTo).toHaveBeenCalledWith(mockMap); + }); + + it('should not run the callback after the child has been destroyed', async () => { + const { mapboxMap } = createDeferredMap(); + const instance = createMarker(mapboxMap); + + vi.useFakeTimers(); + instance.$mount(); + await vi.advanceTimersByTimeAsync(100); + // Destroy before the map ever loads: the pending subscription is flushed. + instance.$destroy(); + await vi.advanceTimersByTimeAsync(100); + vi.useRealTimers(); + + expect(mapboxMap.pending).toBe(0); + + // Firing map-load now must not inject anything on a destroyed child. + mapboxMap.fireLoad(); + expect((instance.marker as any).addTo).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/tests/MapboxMap/MapboxCluster.spec.ts b/packages/tests/MapboxMap/MapboxCluster.spec.ts index 7b0dd7db..85aefb13 100644 --- a/packages/tests/MapboxMap/MapboxCluster.spec.ts +++ b/packages/tests/MapboxMap/MapboxCluster.spec.ts @@ -1,25 +1,22 @@ import { describe, it, expect, vi } from 'vitest'; import { h } from '#test-utils'; import { MockMap } from './mock-mapbox-gl.js'; -import { MapboxCluster } from '@studiometa/ui-mapbox'; +import { MapboxCluster, MapboxClusterItem } from '@studiometa/ui-mapbox'; -function createCluster(attrs: Record = {}, children: (string | Node)[] = []) { +/** + * Build a `MapboxCluster` whose parent map is a ready `MockMap`. + */ +function createCluster(attrs: Record = {}) { const mockMap = new MockMap(); - const el = h( - 'div', - { - 'data-component': 'MapboxCluster', - 'data-option-data': '/points.geojson', - ...attrs, - }, - children, - ); + const el = h('div', { + 'data-component': 'MapboxCluster', + ...attrs, + }); const instance = new MapboxCluster(el); - // Mock $closest since async component resolution doesn't set it up instance.$closest = vi.fn((query: string) => { if (query === 'MapboxMap') { - return { map: mockMap, $options: { accessToken: 'token' } } as any; + return { map: mockMap, isLoaded: true, $options: { accessToken: 'token' } } as any; } return undefined; }); @@ -28,234 +25,180 @@ function createCluster(attrs: Record = {}, children: (string | N } /** - * Build a fake Mapbox mouse event with a working `preventDefault`. + * Build a `MapboxClusterItem` bound to the given cluster. */ -function createMouseEvent() { - return { - point: { x: 0, y: 0 }, - defaultPrevented: false, - preventDefault() { - this.defaultPrevented = true; +function createItem( + cluster: MapboxCluster, + attrs: Record = {}, + children: (string | Node)[] = [], +) { + const el = h( + 'li', + { + 'data-component': 'MapboxClusterItem', + 'data-option-id': '1', + 'data-option-lng-lat': '[2.35, 48.85]', + ...attrs, }, - }; + children, + ); + + const instance = new MapboxClusterItem(el); + instance.$closest = vi.fn((query: string) => (query === 'MapboxCluster' ? cluster : undefined)); + + return instance; +} + +/** + * Mount an instance and flush the js-toolkit mount + any debounced rebuild. + */ +async function mountAndFlush(instance: { $mount(): void }) { + vi.useFakeTimers(); + instance.$mount(); + await vi.advanceTimersByTimeAsync(200); + vi.useRealTimers(); } describe('MapboxCluster component', () => { it('should add a clustered source and three layers on mount', async () => { const { instance, mockMap } = createCluster(); - vi.useFakeTimers(); - instance.$mount(); - await vi.advanceTimersByTimeAsync(100); - vi.useRealTimers(); + await mountAndFlush(instance); const sourceId = (instance as any).__getId('source'); expect(mockMap.addSource).toHaveBeenCalledWith( sourceId, - expect.objectContaining({ type: 'geojson', cluster: true, data: '/points.geojson' }), + expect.objectContaining({ type: 'geojson', cluster: true }), ); expect(mockMap.addLayer).toHaveBeenCalledTimes(3); }); - it('should use inline GeoJSON from the `geojson` script ref as the source data', async () => { - const geojson = { - type: 'FeatureCollection', - features: [ - { type: 'Feature', geometry: { type: 'Point', coordinates: [1, 2] }, properties: {} }, - ], - }; - const script = h('script', { 'data-ref': 'geojson', type: 'application/json' }, [ - JSON.stringify(geojson), - ]); - const { instance, mockMap } = createCluster({}, [script]); + it('should build its FeatureCollection from the registered items', async () => { + const { instance } = createCluster(); + await mountAndFlush(instance); - vi.useFakeTimers(); - instance.$mount(); - await vi.advanceTimersByTimeAsync(100); - vi.useRealTimers(); + const itemA = createItem(instance, { 'data-option-id': 'a', 'data-option-lng-lat': '[1, 2]' }); + const itemB = createItem(instance, { + 'data-option-id': 'b', + 'data-option-lng-lat': '[3, 4]', + 'data-option-properties': '{"label":"B"}', + }); - const sourceId = (instance as any).__getId('source'); - expect(mockMap.addSource).toHaveBeenCalledWith( - sourceId, - expect.objectContaining({ type: 'geojson', cluster: true, data: geojson }), - ); - }); + await mountAndFlush(itemA); + await mountAndFlush(itemB); - it('should fall back to the `data` URL when the `geojson` ref is empty', async () => { - // A present but empty (or whitespace-only) script ref must be treated as - // "no inline data" and fall back to the `data` URL option, not inject - // `null` (which `JSON.parse('null')` would otherwise produce). - const script = h('script', { 'data-ref': 'geojson', type: 'application/json' }, []); - const { instance, mockMap } = createCluster({}, [script]); + const fc = instance.featureCollection; + expect(fc.type).toBe('FeatureCollection'); + expect(fc.features).toHaveLength(2); + expect(fc.features[0]).toMatchObject({ + geometry: { type: 'Point', coordinates: [1, 2] }, + properties: { id: 'a' }, + }); + expect(fc.features[1].properties).toMatchObject({ id: 'b', label: 'B' }); + }); - vi.useFakeTimers(); - instance.$mount(); - await vi.advanceTimersByTimeAsync(100); - vi.useRealTimers(); + it('should push the derived data to the source when an item registers', async () => { + const { instance, mockMap } = createCluster(); + await mountAndFlush(instance); const sourceId = (instance as any).__getId('source'); - expect(mockMap.addSource).toHaveBeenCalledWith( - sourceId, - expect.objectContaining({ type: 'geojson', cluster: true, data: '/points.geojson' }), - ); - }); - - it('should warn and fall back to the URL when the `geojson` ref holds invalid JSON', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const script = h('script', { 'data-ref': 'geojson', type: 'application/json' }, ['{ invalid']); - // `data-option-log` makes `$warn` emit to `console.warn`. - const { instance, mockMap } = createCluster({ 'data-option-log': '' }, [script]); + const source = mockMap.getSource(sourceId); - vi.useFakeTimers(); - expect(() => { - instance.$mount(); - }).not.toThrow(); - await vi.advanceTimersByTimeAsync(100); - vi.useRealTimers(); + const item = createItem(instance, { 'data-option-id': 'x', 'data-option-lng-lat': '[5, 6]' }); + await mountAndFlush(item); - expect(warn).toHaveBeenCalled(); - const sourceId = (instance as any).__getId('source'); - expect(mockMap.addSource).toHaveBeenCalledWith( - sourceId, - expect.objectContaining({ type: 'geojson', cluster: true, data: '/points.geojson' }), - ); - warn.mockRestore(); + expect(source.setData).toHaveBeenCalled(); + const lastData = source.setData.mock.calls.at(-1)[0]; + expect(lastData.features).toHaveLength(1); + expect(lastData.features[0].properties.id).toBe('x'); }); - it('should emit cluster-click and ease to the expansion zoom on cluster click', async () => { + it('should drop an item from the data when it unregisters', async () => { const { instance, mockMap } = createCluster(); - const handler = vi.fn(); + await mountAndFlush(instance); - mockMap.queryRenderedFeatures = vi.fn(() => [ - { - properties: { cluster_id: 42 }, - geometry: { type: 'Point', coordinates: [1, 2] }, - }, - ]) as any; + const item = createItem(instance, { 'data-option-id': 'x', 'data-option-lng-lat': '[5, 6]' }); + await mountAndFlush(item); - vi.useFakeTimers(); - instance.$mount(); - await vi.advanceTimersByTimeAsync(100); - instance.$on('cluster-click', handler); + const sourceId = (instance as any).__getId('source'); + const source = mockMap.getSource(sourceId); - const clustersId = (instance as any).__getId('clusters'); - const event = createMouseEvent(); - mockMap.fire('click', clustersId, event); - await vi.advanceTimersByTimeAsync(100); + vi.useFakeTimers(); + item.$destroy(); + await vi.advanceTimersByTimeAsync(200); vi.useRealTimers(); - expect(handler).toHaveBeenCalledTimes(1); - expect(handler.mock.calls[0][0].detail[0]).toBe(42); - expect(mockMap.easeTo).toHaveBeenCalledWith({ center: [1, 2], zoom: 5 }); + const lastData = source.setData.mock.calls.at(-1)[0]; + expect(lastData.features).toHaveLength(0); }); - it('should not ease to the expansion zoom when the event is default-prevented', async () => { + it('should fly to, mark active and open a popup on the selected item', async () => { const { instance, mockMap } = createCluster(); + await mountAndFlush(instance); - mockMap.queryRenderedFeatures = vi.fn(() => [ - { - properties: { cluster_id: 42 }, - geometry: { type: 'Point', coordinates: [1, 2] }, - }, - ]) as any; - - vi.useFakeTimers(); - instance.$mount(); - await vi.advanceTimersByTimeAsync(100); - instance.$on('cluster-click', (event: CustomEvent) => { - // The second emitted arg is the original mouse event. - event.detail[1].preventDefault(); - }); + const item = createItem(instance, { 'data-option-id': 'x', 'data-option-lng-lat': '[5, 6]' }, [ + '

Store X

', + ]); + await mountAndFlush(item); - const clustersId = (instance as any).__getId('clusters'); - mockMap.fire('click', clustersId, createMouseEvent()); - await vi.advanceTimersByTimeAsync(100); - vi.useRealTimers(); + instance.selectItem(item); - expect(mockMap.easeTo).not.toHaveBeenCalled(); + expect(mockMap.flyTo).toHaveBeenCalledWith(expect.objectContaining({ center: [5, 6] })); + expect(item.$el.hasAttribute('data-active')).toBe(true); + expect(item.$el.getAttribute('aria-current')).toBe('true'); }); - it('should do nothing on cluster click when no feature is found', async () => { + it('should emit cluster-click and ease to the expansion zoom on cluster click', async () => { const { instance, mockMap } = createCluster(); const handler = vi.fn(); - // Default queryRenderedFeatures returns an empty array. - vi.useFakeTimers(); - instance.$mount(); - await vi.advanceTimersByTimeAsync(100); + mockMap.queryRenderedFeatures = vi.fn(() => [ + { properties: { cluster_id: 42 }, geometry: { type: 'Point', coordinates: [1, 2] } }, + ]) as any; + + await mountAndFlush(instance); instance.$on('cluster-click', handler); const clustersId = (instance as any).__getId('clusters'); - mockMap.fire('click', clustersId, createMouseEvent()); - await vi.advanceTimersByTimeAsync(100); - vi.useRealTimers(); - - expect(handler).not.toHaveBeenCalled(); - expect(mockMap.easeTo).not.toHaveBeenCalled(); - }); - - it('should default to an empty FeatureCollection source when no data is authored', async () => { - // No `data` URL and no `geojson` ref: the source must still be created with - // an empty FeatureCollection so a coordinator can drive it via `setData`. - const { instance, mockMap } = createCluster({ 'data-option-data': '' }); - - vi.useFakeTimers(); - instance.$mount(); - await vi.advanceTimersByTimeAsync(100); - vi.useRealTimers(); + mockMap.fire('click', clustersId, { + point: { x: 0, y: 0 }, + defaultPrevented: false, + preventDefault() {}, + }); - const sourceId = (instance as any).__getId('source'); - expect(mockMap.addSource).toHaveBeenCalledWith( - sourceId, - expect.objectContaining({ - type: 'geojson', - cluster: true, - data: { type: 'FeatureCollection', features: [] }, - }), - ); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].detail[0]).toBe(42); + expect(mockMap.easeTo).toHaveBeenCalledWith({ center: [1, 2], zoom: 5 }); }); - it('should replace the live source data through setData', async () => { + it('should select the item behind a clicked unclustered point', async () => { const { instance, mockMap } = createCluster(); + await mountAndFlush(instance); - vi.useFakeTimers(); - instance.$mount(); - await vi.advanceTimersByTimeAsync(100); - vi.useRealTimers(); - - const sourceId = (instance as any).__getId('source'); - const source = mockMap.getSource(sourceId); - const nextData = { - type: 'FeatureCollection', - features: [ - { type: 'Feature', geometry: { type: 'Point', coordinates: [9, 9] }, properties: { id: '1' } }, - ], - }; + const item = createItem(instance, { 'data-option-id': 'x', 'data-option-lng-lat': '[5, 6]' }); + await mountAndFlush(item); - instance.setData(nextData as any); + const unclusteredId = (instance as any).__getId('unclustered-point'); + mockMap.fire('click', unclusteredId, { + features: [{ properties: { id: 'x' } }], + defaultPrevented: false, + preventDefault() {}, + }); - expect(source.setData).toHaveBeenCalledWith(nextData); - expect(source.data).toBe(nextData); - }); - - it('should not throw when setData is called before mount (no source yet)', () => { - const { instance } = createCluster(); - // Never mounted: the source does not exist. setData must be a safe no-op. - expect(() => instance.setData({ type: 'FeatureCollection', features: [] } as any)).not.toThrow(); + expect(item.$el.hasAttribute('data-active')).toBe(true); + expect(mockMap.flyTo).toHaveBeenCalled(); }); it('should remove the three layers and the source on destroy', async () => { const { instance, mockMap } = createCluster(); - - vi.useFakeTimers(); - instance.$mount(); - await vi.advanceTimersByTimeAsync(100); + await mountAndFlush(instance); const clustersId = (instance as any).__getId('clusters'); const clusterCountId = (instance as any).__getId('cluster-count'); const unclusteredPointId = (instance as any).__getId('unclustered-point'); const sourceId = (instance as any).__getId('source'); + vi.useFakeTimers(); instance.$destroy(); await vi.advanceTimersByTimeAsync(100); vi.useRealTimers(); @@ -265,32 +208,80 @@ describe('MapboxCluster component', () => { expect(mockMap.removeLayer).toHaveBeenCalledWith(unclusteredPointId); expect(mockMap.removeSource).toHaveBeenCalledWith(sourceId); }); +}); - it('should detach the cluster click listener on destroy', async () => { - const { instance, mockMap } = createCluster(); - const handler = vi.fn(); +describe('MapboxClusterItem component', () => { + it('should register with the cluster on mount', async () => { + const { instance: cluster } = createCluster(); + await mountAndFlush(cluster); + const register = vi.spyOn(cluster, 'register'); - mockMap.queryRenderedFeatures = vi.fn(() => [ - { - properties: { cluster_id: 42 }, - geometry: { type: 'Point', coordinates: [1, 2] }, - }, - ]) as any; + const item = createItem(cluster); + await mountAndFlush(item); - vi.useFakeTimers(); - instance.$mount(); - await vi.advanceTimersByTimeAsync(100); - instance.$on('cluster-click', handler); + expect(register).toHaveBeenCalledWith(item); + expect(cluster.featureCollection.features).toHaveLength(1); + }); - const clustersId = (instance as any).__getId('clusters'); - instance.$destroy(); - await vi.advanceTimersByTimeAsync(100); + it('should unregister from the cached cluster on destroy, even when detached', async () => { + const { instance: cluster } = createCluster(); + await mountAndFlush(cluster); - // Firing after destroy should not trigger the detached handler. - mockMap.fire('click', clustersId, createMouseEvent()); - await vi.advanceTimersByTimeAsync(100); + const item = createItem(cluster); + await mountAndFlush(item); + expect(cluster.featureCollection.features).toHaveLength(1); + + const unregister = vi.spyOn(cluster, 'unregister'); + // Simulate the element being detached from the DOM before teardown: the + // `$closest` mock would return the cluster still, so make it return undefined + // to prove the cached reference (not a fresh lookup) drives the unregister. + (item as any).$closest = vi.fn(() => undefined); + + vi.useFakeTimers(); + item.$destroy(); + await vi.advanceTimersByTimeAsync(200); vi.useRealTimers(); - expect(handler).not.toHaveBeenCalled(); + expect(unregister).toHaveBeenCalledWith(item); + expect(cluster.featureCollection.features).toHaveLength(0); + }); + + it('should expose id, lngLat and properties', async () => { + const { instance: cluster } = createCluster(); + const item = createItem(cluster, { + 'data-option-id': 'store-1', + 'data-option-lng-lat': '[7, 8]', + 'data-option-properties': '{"city":"Paris"}', + }); + + expect(item.id).toBe('store-1'); + expect(item.lngLat).toEqual([7, 8]); + expect(item.properties).toEqual({ city: 'Paris' }); + }); + + it('should use the [data-ref="popup"] content as the popup content when present', () => { + const { instance: cluster } = createCluster(); + const popup = h('div', { 'data-ref': 'popup' }); + popup.innerHTML = 'Popup only'; + const item = createItem(cluster, {}, ['Card text', popup]); + + expect(item.popupContent).toBe('Popup only'); + }); + + it('should reflect in-bounds and active state as attributes', () => { + const { instance: cluster } = createCluster(); + const item = createItem(cluster); + + item.setInBounds(true); + expect(item.$el.hasAttribute('data-in-bounds')).toBe(true); + item.setInBounds(false); + expect(item.$el.hasAttribute('data-in-bounds')).toBe(false); + + item.setActive(true); + expect(item.$el.hasAttribute('data-active')).toBe(true); + expect(item.$el.getAttribute('aria-current')).toBe('true'); + item.setActive(false); + expect(item.$el.hasAttribute('data-active')).toBe(false); + expect(item.$el.hasAttribute('aria-current')).toBe(false); }); }); diff --git a/packages/tests/MapboxMap/MapboxFullscreenControl.spec.ts b/packages/tests/MapboxMap/MapboxFullscreenControl.spec.ts index 9a2e3795..d4e7be9c 100644 --- a/packages/tests/MapboxMap/MapboxFullscreenControl.spec.ts +++ b/packages/tests/MapboxMap/MapboxFullscreenControl.spec.ts @@ -14,7 +14,7 @@ function createControl(attrs: Record = {}) { // Mock $closest since async component resolution doesn't set it up instance.$closest = vi.fn((query: string) => { if (query === 'MapboxMap') { - return { map: mockMap, $options: { accessToken: 'token' } } as any; + return { map: mockMap, isLoaded: true, $options: { accessToken: 'token' } } as any; } return undefined; }); diff --git a/packages/tests/MapboxMap/MapboxGeocoder.spec.ts b/packages/tests/MapboxMap/MapboxGeocoder.spec.ts index 2eb799ef..df763175 100644 --- a/packages/tests/MapboxMap/MapboxGeocoder.spec.ts +++ b/packages/tests/MapboxMap/MapboxGeocoder.spec.ts @@ -39,7 +39,7 @@ function createGeocoder(attrs: Record = {}) { // Mock $closest since async component resolution doesn't set it up instance.$closest = vi.fn((query: string) => { if (query === 'MapboxMap') { - return { map: mockMap, $options: { accessToken: 'parent-token' } } as any; + return { map: mockMap, isLoaded: true, $options: { accessToken: 'parent-token' } } as any; } return undefined; }); diff --git a/packages/tests/MapboxMap/MapboxGeocoder.teardown.spec.ts b/packages/tests/MapboxMap/MapboxGeocoder.teardown.spec.ts index da48fdcf..97d695c0 100644 --- a/packages/tests/MapboxMap/MapboxGeocoder.teardown.spec.ts +++ b/packages/tests/MapboxMap/MapboxGeocoder.teardown.spec.ts @@ -57,7 +57,7 @@ function createGeocoder() { // Mock $closest since async component resolution doesn't set it up. instance.$closest = vi.fn((query: string) => { if (query === 'MapboxMap') { - return { map: mockMap, $options: { accessToken: 'parent-token' } } as any; + return { map: mockMap, isLoaded: true, $options: { accessToken: 'parent-token' } } as any; } return undefined; }); diff --git a/packages/tests/MapboxMap/MapboxGeolocateControl.spec.ts b/packages/tests/MapboxMap/MapboxGeolocateControl.spec.ts index 48e01925..5c66ebd8 100644 --- a/packages/tests/MapboxMap/MapboxGeolocateControl.spec.ts +++ b/packages/tests/MapboxMap/MapboxGeolocateControl.spec.ts @@ -14,7 +14,7 @@ function createControl(attrs: Record = {}) { // Mock $closest since async component resolution doesn't set it up instance.$closest = vi.fn((query: string) => { if (query === 'MapboxMap') { - return { map: mockMap, $options: { accessToken: 'token' } } as any; + return { map: mockMap, isLoaded: true, $options: { accessToken: 'token' } } as any; } return undefined; }); diff --git a/packages/tests/MapboxMap/MapboxImage.spec.ts b/packages/tests/MapboxMap/MapboxImage.spec.ts index 61bb501a..aeeb9828 100644 --- a/packages/tests/MapboxMap/MapboxImage.spec.ts +++ b/packages/tests/MapboxMap/MapboxImage.spec.ts @@ -16,7 +16,7 @@ function createImage(attrs: Record = {}) { // Mock $closest since async component resolution doesn't set it up instance.$closest = vi.fn((query: string) => { if (query === 'MapboxMap') { - return { map: mockMap, $options: { accessToken: 'token' } } as any; + return { map: mockMap, isLoaded: true, $options: { accessToken: 'token' } } as any; } return undefined; }); diff --git a/packages/tests/MapboxMap/MapboxImages.spec.ts b/packages/tests/MapboxMap/MapboxImages.spec.ts index e8a484cc..cfcf3721 100644 --- a/packages/tests/MapboxMap/MapboxImages.spec.ts +++ b/packages/tests/MapboxMap/MapboxImages.spec.ts @@ -16,7 +16,7 @@ function createImages(attrs: Record = {}) { // Mock $closest since async component resolution doesn't set it up instance.$closest = vi.fn((query: string) => { if (query === 'MapboxMap') { - return { map: mockMap, $options: { accessToken: 'token' } } as any; + return { map: mockMap, isLoaded: true, $options: { accessToken: 'token' } } as any; } return undefined; }); @@ -59,7 +59,7 @@ describe('MapboxImages component', () => { const instance = new MapboxImages(el); instance.$closest = vi.fn((query: string) => { if (query === 'MapboxMap') { - return { map: mockMap, $options: {} } as any; + return { map: mockMap, isLoaded: true, $options: {} } as any; } return undefined; }); diff --git a/packages/tests/MapboxMap/MapboxLayer.spec.ts b/packages/tests/MapboxMap/MapboxLayer.spec.ts index acf96fcc..70fe6587 100644 --- a/packages/tests/MapboxMap/MapboxLayer.spec.ts +++ b/packages/tests/MapboxMap/MapboxLayer.spec.ts @@ -22,7 +22,7 @@ function createLayer(attrs: Record = {}, { withSource = true } = // Mock $closest since async component resolution doesn't set it up instance.$closest = vi.fn((query: string) => { if (query === 'MapboxMap') { - return { map: mockMap, $options: { accessToken: 'token' } } as any; + return { map: mockMap, isLoaded: true, $options: { accessToken: 'token' } } as any; } return undefined; }); diff --git a/packages/tests/MapboxMap/MapboxMarker.spec.ts b/packages/tests/MapboxMap/MapboxMarker.spec.ts index 38361e52..d01abfdc 100644 --- a/packages/tests/MapboxMap/MapboxMarker.spec.ts +++ b/packages/tests/MapboxMap/MapboxMarker.spec.ts @@ -15,7 +15,7 @@ function createMarker(attrs: Record = {}) { // Mock $closest since async component resolution doesn't set it up instance.$closest = vi.fn((query: string) => { if (query === 'MapboxMap') { - return { map: mockMap, $options: { accessToken: 'token' } } as any; + return { map: mockMap, isLoaded: true, $options: { accessToken: 'token' } } as any; } return undefined; }); @@ -54,7 +54,7 @@ describe('MapboxMarker component', () => { const instance = new MapboxMarker(el); instance.$closest = vi.fn((query: string) => { if (query === 'MapboxMap') { - return { map: mockMap, $options: {} } as any; + return { map: mockMap, isLoaded: true, $options: {} } as any; } return undefined; }); diff --git a/packages/tests/MapboxMap/MapboxNavigationControl.spec.ts b/packages/tests/MapboxMap/MapboxNavigationControl.spec.ts index 50616b58..0eb7814b 100644 --- a/packages/tests/MapboxMap/MapboxNavigationControl.spec.ts +++ b/packages/tests/MapboxMap/MapboxNavigationControl.spec.ts @@ -14,7 +14,7 @@ function createControl(attrs: Record = {}) { // Mock $closest since async component resolution doesn't set it up instance.$closest = vi.fn((query: string) => { if (query === 'MapboxMap') { - return { map: mockMap, $options: { accessToken: 'token' } } as any; + return { map: mockMap, isLoaded: true, $options: { accessToken: 'token' } } as any; } return undefined; }); diff --git a/packages/tests/MapboxMap/MapboxPopup.spec.ts b/packages/tests/MapboxMap/MapboxPopup.spec.ts index c7eec3a6..db91980e 100644 --- a/packages/tests/MapboxMap/MapboxPopup.spec.ts +++ b/packages/tests/MapboxMap/MapboxPopup.spec.ts @@ -15,7 +15,7 @@ function createPopup(attrs: Record = {}, isMapParent = true) { // Mock $closest since async component resolution doesn't set it up instance.$closest = vi.fn((query: string) => { if (query === 'MapboxMap') { - return { map: mockMap, $options: { accessToken: 'token' } } as any; + return { map: mockMap, isLoaded: true, $options: { accessToken: 'token' } } as any; } if (query === 'MapboxMarker') { // If isMapParent is true, we're not inside a marker @@ -57,7 +57,7 @@ describe('MapboxPopup component', () => { const inst = new MapboxPopup(el); inst.$closest = vi.fn((query: string) => { if (query === 'MapboxMap') { - return { map: mockMap, $options: {} } as any; + return { map: mockMap, isLoaded: true, $options: {} } as any; } if (query === 'MapboxMarker') { return undefined; diff --git a/packages/tests/MapboxMap/MapboxSource.spec.ts b/packages/tests/MapboxMap/MapboxSource.spec.ts index a17bb70b..e6765d92 100644 --- a/packages/tests/MapboxMap/MapboxSource.spec.ts +++ b/packages/tests/MapboxMap/MapboxSource.spec.ts @@ -20,7 +20,7 @@ function createSource(attrs: Record = {}, children: (string | No // Mock $closest since async component resolution doesn't set it up instance.$closest = vi.fn((query: string) => { if (query === 'MapboxMap') { - return { map: mockMap, $options: { accessToken: 'token' } } as any; + return { map: mockMap, isLoaded: true, $options: { accessToken: 'token' } } as any; } return undefined; }); diff --git a/packages/tests/MapboxMap/StoreLocator.spec.ts b/packages/tests/MapboxMap/StoreLocator.spec.ts deleted file mode 100644 index 6834eab1..00000000 --- a/packages/tests/MapboxMap/StoreLocator.spec.ts +++ /dev/null @@ -1,727 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -// Importing the mock first registers the `mapbox-gl` module mock before the -// package (and its real `mapbox-gl` dependency) is imported below. -import { MockMap } from './mock-mapbox-gl.js'; -import { h } from '#test-utils'; -import { StoreLocator, StoreLocatorItem } from '@studiometa/ui-mapbox'; - -/** - * A minimal `StoreLocatorItem` stand-in used to exercise the coordinator's - * registry, coalesced sync and derived data without mounting a real component. - * - * It exposes exactly the surface the coordinator touches: `id`, `lngLat`, an - * `$el` (moved around by `__reorderList`) and the `setInBounds` / `setActive` - * state setters (spied and reflected as data-attributes, mirroring the real - * component so DOM assertions stay meaningful). - */ -function fakeItem(id: string, lngLat: [number, number]) { - const el = h('li', { 'data-component': 'StoreLocatorItem' }) as HTMLElement; - return { - id, - lngLat, - $el: el, - setInBounds: vi.fn((value: boolean) => el.toggleAttribute('data-in-bounds', value)), - setActive: vi.fn((value: boolean) => { - el.toggleAttribute('data-active', value); - if (value) { - el.setAttribute('aria-current', 'true'); - } else { - el.removeAttribute('aria-current'); - } - }), - }; -} - -/** - * Build a `StoreLocator` with a sidebar list of `StoreLocatorItem`s and a mocked - * map plumbing. - * - * Like the other `@studiometa/ui-mapbox` child specs (which mock the parent - * `MapboxMap` rather than mounting a real one), the map, cluster and geocoder and - * their `map-load` / `feature-click` / `result` events are injected through the - * `mapboxMap`, `cluster` and `geocoder` getters. This keeps the test deterministic - * and free of the real `mapbox-gl` (which throws in a headless WebGL-less - * environment). - * - * The child `$on` doubles return real unsubscribe callbacks, so the coordinator's - * `destroyed()` teardown can be exercised: after destroy, firing an event finds no - * handler left. - */ -function createStoreLocator( - items: Array<{ id: string; lngLat: [number, number] }>, - options: { - attrs?: Record; - geocoder?: boolean; - // Simulate a geocoder that mounts *after* the cluster: the `geocoder` getter - // returns `undefined` for the first N reads (attempts), then the mock. Lets a - // test drive the cluster-before-geocoder wiring race. - geocoderReadyAfter?: number; - } = {}, -) { - const listItems = items.map((item) => - h('li', { 'data-component': 'StoreLocatorItem', 'data-option-id': item.id }, [ - h('button', { 'data-ref': 'select' }, [item.id]), - ]), - ); - listItems.forEach((el, index) => { - el.setAttribute('data-option-lng-lat', JSON.stringify(items[index].lngLat)); - }); - - const root = h('div', { 'data-component': 'StoreLocator', ...(options.attrs ?? {}) }, [ - h('ul', { 'data-ref': 'list' }, listItems), - ]); - const instance = new StoreLocator(root); - - const mockMap = new MockMap(); - const mapLoadHandlers: Array<() => void> = []; - const clusterHandlers: Record void>> = {}; - const geocoderHandlers: Record void>> = {}; - - function off(bucket: Array<(event: unknown) => void>, callback: (event: unknown) => void) { - return () => { - const index = bucket.indexOf(callback); - if (index > -1) bucket.splice(index, 1); - }; - } - - const mockMapbox = { - isLoaded: false, - map: mockMap, - $on(event: string, callback: () => void) { - if (event === 'map-load') { - mapLoadHandlers.push(callback); - return off(mapLoadHandlers as any, callback as any); - } - return () => {}; - }, - }; - const mockCluster = { - // The coordinator only wires and feeds the cluster once it is fully mounted - // (its GeoJSON source is added from `mounted()`), so the mock advertises it. - $isMounted: true, - setData: vi.fn(), - $on(event: string, callback: (event: unknown) => void) { - (clusterHandlers[event] ??= []).push(callback); - return off(clusterHandlers[event], callback); - }, - }; - const mockGeocoder = - options.geocoder || options.geocoderReadyAfter !== undefined - ? { - $on(event: string, callback: (event: unknown) => void) { - (geocoderHandlers[event] ??= []).push(callback); - return off(geocoderHandlers[event], callback); - }, - } - : undefined; - - // When `geocoderReadyAfter` is set, the geocoder is not queryable yet: return - // `undefined` for the first N reads (mimicking an async mount that lands after - // the cluster's), then hand out the mock. - let geocoderReads = 0; - function geocoderGetter() { - if (options.geocoderReadyAfter !== undefined && geocoderReads++ < options.geocoderReadyAfter) { - return undefined; - } - return mockGeocoder; - } - - Object.defineProperty(instance, 'mapboxMap', { get: () => mockMapbox, configurable: true }); - Object.defineProperty(instance, 'cluster', { get: () => mockCluster, configurable: true }); - Object.defineProperty(instance, 'geocoder', { get: geocoderGetter, configurable: true }); - - return { - instance, - mockMap: mockMap as unknown as MockMap, - mockCluster, - items: () => instance.$query('StoreLocatorItem'), - item: (id: string) => - instance.$query('StoreLocatorItem').find((entry) => entry.id === id)!, - fireLoad() { - mapLoadHandlers.forEach((callback) => callback()); - }, - fireMoveEnd() { - mockMap.fire('moveend'); - }, - fireFeatureClick(feature: unknown) { - (clusterHandlers['feature-click'] ?? []).forEach((callback) => - callback({ detail: [feature, {}] }), - ); - }, - fireGeocoderResult(result: unknown) { - (geocoderHandlers['result'] ?? []).forEach((callback) => callback({ detail: [result] })); - }, - }; -} - -/** - * Mount the component, let the js-toolkit timer-based mount settle, then simulate - * the map load so the coordinator wires itself. Leaves real timers active. - */ -async function mountAndLoad(ctx: ReturnType) { - vi.useFakeTimers(); - ctx.instance.$mount(); - await vi.advanceTimersByTimeAsync(100); - ctx.fireLoad(); - await vi.advanceTimersByTimeAsync(100); - vi.useRealTimers(); -} - -/** - * Configure the mock viewport so only the given longitudes are considered inside - * bounds, letting a test drive the in-bounds axis independently of the map data. - */ -function boundsContainingLng(ctx: ReturnType, lngs: number[]) { - ctx.mockMap.getBounds = vi.fn(() => ({ - contains: (lngLat: [number, number]) => lngs.includes(lngLat[0]), - })) as any; -} - -/** - * Read the current DOM order of item ids inside the `list` ref. - */ -function listOrder(ctx: ReturnType): string[] { - const list = (ctx.instance as any).$refs.list as HTMLElement; - return [...list.children] - .map((child) => child.getAttribute('data-option-id')) - .filter((id): id is string => id !== null); -} - -describe('StoreLocator component', () => { - // --- 1. Registry & derived data ------------------------------------------ - describe('registry & derived FeatureCollection', () => { - it('derives one feature per registered item with id + [lng,lat] coordinates', async () => { - const ctx = createStoreLocator([ - { id: 'a', lngLat: [1, 1] }, - { id: 'b', lngLat: [2, 2] }, - ]); - await mountAndLoad(ctx); - - expect(ctx.items()).toHaveLength(2); - - const { features } = ctx.instance.featureCollection; - expect(features).toHaveLength(2); - expect(features[0]).toMatchObject({ - type: 'Feature', - geometry: { type: 'Point', coordinates: [1, 1] }, - properties: { id: 'a' }, - }); - expect(features.map((feature) => feature.properties.id)).toEqual(['a', 'b']); - }); - - it('removes a feature when an item is unregistered', async () => { - const ctx = createStoreLocator([ - { id: 'a', lngLat: [1, 1] }, - { id: 'b', lngLat: [2, 2] }, - ]); - await mountAndLoad(ctx); - - ctx.instance.unregisterItem(ctx.item('a')); - - expect(ctx.instance.featureCollection.features.map((f) => f.properties.id)).toEqual(['b']); - }); - - it('treats a duplicate register of the same item as a no-op', async () => { - const ctx = createStoreLocator([{ id: 'a', lngLat: [1, 1] }]); - await mountAndLoad(ctx); - - const itemA = ctx.item('a'); - ctx.instance.registerItem(itemA); - ctx.instance.registerItem(itemA); - - expect(ctx.instance.featureCollection.features).toHaveLength(1); - }); - - it('pushes the derived data to the cluster once the item set is registered and loaded', async () => { - const ctx = createStoreLocator([{ id: 'a', lngLat: [1, 1] }]); - await mountAndLoad(ctx); - - expect(ctx.mockCluster.setData).toHaveBeenCalled(); - const lastCall = ctx.mockCluster.setData.mock.calls.at(-1)?.[0] as { features: unknown[] }; - expect(lastCall.features).toHaveLength(1); - }); - }); - - // --- 2. Debounced / coalesced sync (the Fetch-swap case) ----------------- - describe('coalesced item-set sync', () => { - it('calls cluster.setData ONCE for a batch registered across the same tick', async () => { - const ctx = createStoreLocator([{ id: 'a', lngLat: [1, 1] }]); - await mountAndLoad(ctx); - - ctx.mockCluster.setData.mockClear(); - - vi.useFakeTimers(); - // Simulate a Fetch swapping the list: a whole batch registers in one tick. - const batch = [ - fakeItem('x', [3, 3]), - fakeItem('y', [4, 4]), - fakeItem('z', [5, 5]), - ]; - batch.forEach((item) => ctx.instance.registerItem(item as unknown as StoreLocatorItem)); - // Nothing has flushed yet: the sync is debounced. - expect(ctx.mockCluster.setData).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(100); - vi.useRealTimers(); - - expect(ctx.mockCluster.setData).toHaveBeenCalledTimes(1); - const data = ctx.mockCluster.setData.mock.calls[0][0] as { features: Array<{ properties: { id: string } }> }; - expect(data.features.map((f) => f.properties.id)).toEqual(['a', 'x', 'y', 'z']); - }); - - it('ends a swap (unregister old + register new) with the new set on the map', async () => { - const ctx = createStoreLocator([ - { id: 'a', lngLat: [1, 1] }, - { id: 'b', lngLat: [2, 2] }, - ]); - await mountAndLoad(ctx); - - ctx.mockCluster.setData.mockClear(); - - vi.useFakeTimers(); - // Swap: drop the two mounted items and register a fresh batch in one tick. - ctx.instance.unregisterItem(ctx.item('a')); - ctx.instance.unregisterItem(ctx.item('b')); - const fresh = [fakeItem('c', [7, 7]), fakeItem('d', [8, 8])]; - fresh.forEach((item) => ctx.instance.registerItem(item as unknown as StoreLocatorItem)); - - await vi.advanceTimersByTimeAsync(100); - vi.useRealTimers(); - - expect(ctx.mockCluster.setData).toHaveBeenCalledTimes(1); - const data = ctx.mockCluster.setData.mock.calls[0][0] as { features: Array<{ properties: { id: string } }> }; - expect(data.features.map((f) => f.properties.id)).toEqual(['c', 'd']); - }); - }); - - // --- 3. Two-axis independence (registered vs in-bounds) ------------------ - describe('two-axis independence: in-bounds filtering never rebuilds map data', () => { - it('reflects data-in-bounds only for in-view items and emits filter with them', async () => { - const ctx = createStoreLocator([ - { id: 'a', lngLat: [1, 1] }, - { id: 'b', lngLat: [2, 2] }, - { id: 'c', lngLat: [3, 3] }, - ]); - await mountAndLoad(ctx); - - // Only `a` and `c` fall inside the viewport. - boundsContainingLng(ctx, [1, 3]); - - const filter = vi.fn(); - ctx.instance.$on('filter', filter); - - ctx.fireMoveEnd(); - - expect(ctx.item('a').$el.hasAttribute('data-in-bounds')).toBe(true); - expect(ctx.item('b').$el.hasAttribute('data-in-bounds')).toBe(false); - expect(ctx.item('c').$el.hasAttribute('data-in-bounds')).toBe(true); - - expect(filter).toHaveBeenCalledTimes(1); - const inView = filter.mock.calls[0][0].detail[0] as StoreLocatorItem[]; - expect(inView.map((entry) => entry.id).sort()).toEqual(['a', 'c']); - }); - - it('does NOT call cluster.setData on a moveend (pan must not rebuild map data)', async () => { - const ctx = createStoreLocator([ - { id: 'a', lngLat: [1, 1] }, - { id: 'b', lngLat: [2, 2] }, - ]); - await mountAndLoad(ctx); - - boundsContainingLng(ctx, [1]); - - const before = ctx.mockCluster.setData.mock.calls.length; - ctx.fireMoveEnd(); - ctx.fireMoveEnd(); - const after = ctx.mockCluster.setData.mock.calls.length; - - // The critical correctness guarantee: panning recomputes the in-view list - // but never pushes data to the source again. - expect(after).toBe(before); - }); - }); - - // --- 4. Distance sort ---------------------------------------------------- - describe('distance sort', () => { - it('orders the filter payload and the list DOM ascending by distance to center (sort ON)', async () => { - // Center is MockLngLat(0,0); planar distances: b(1,1) < c(2,2) < a(3,3). - const ctx = createStoreLocator([ - { id: 'a', lngLat: [3, 3] }, - { id: 'b', lngLat: [1, 1] }, - { id: 'c', lngLat: [2, 2] }, - ]); - await mountAndLoad(ctx); - - const filter = vi.fn(); - ctx.instance.$on('filter', filter); - - ctx.fireMoveEnd(); - - const inView = filter.mock.calls[0][0].detail[0] as StoreLocatorItem[]; - expect(inView.map((entry) => entry.id)).toEqual(['b', 'c', 'a']); - expect(listOrder(ctx)).toEqual(['b', 'c', 'a']); - }); - - it('preserves registration/DOM order when data-option-no-sort is set', async () => { - const ctx = createStoreLocator( - [ - { id: 'a', lngLat: [3, 3] }, - { id: 'b', lngLat: [1, 1] }, - { id: 'c', lngLat: [2, 2] }, - ], - { attrs: { 'data-option-no-sort': '' } }, - ); - await mountAndLoad(ctx); - - const filter = vi.fn(); - ctx.instance.$on('filter', filter); - - ctx.fireMoveEnd(); - - const inView = filter.mock.calls[0][0].detail[0] as StoreLocatorItem[]; - expect(inView.map((entry) => entry.id)).toEqual(['a', 'b', 'c']); - expect(listOrder(ctx)).toEqual(['a', 'b', 'c']); - }); - }); - - // --- 5. Selection -------------------------------------------------------- - describe('selection', () => { - it('flies to the item, marks it active/aria-current and emits select', async () => { - const ctx = createStoreLocator([{ id: 'a', lngLat: [3, 4] }]); - await mountAndLoad(ctx); - - const select = vi.fn(); - ctx.instance.$on('select', select); - - const itemA = ctx.item('a'); - ctx.instance.selectItem(itemA); - - expect(ctx.mockMap.flyTo).toHaveBeenCalledWith({ center: [3, 4], zoom: 14 }); - expect(itemA.$el.hasAttribute('data-active')).toBe(true); - expect(itemA.$el.getAttribute('aria-current')).toBe('true'); - expect(select).toHaveBeenCalledTimes(1); - expect(select.mock.calls[0][0].detail[0]).toBe(itemA); - }); - - it('honors a custom data-option-item-zoom-level on fly-to', async () => { - const ctx = createStoreLocator([{ id: 'a', lngLat: [3, 4] }], { - attrs: { 'data-option-item-zoom-level': '17' }, - }); - await mountAndLoad(ctx); - - ctx.instance.selectItem(ctx.item('a')); - expect(ctx.mockMap.flyTo).toHaveBeenCalledWith({ center: [3, 4], zoom: 17 }); - }); - - it('deactivates the previously selected item when a second is selected', async () => { - const ctx = createStoreLocator([ - { id: 'a', lngLat: [1, 1] }, - { id: 'b', lngLat: [2, 2] }, - ]); - await mountAndLoad(ctx); - - const itemA = ctx.item('a'); - const itemB = ctx.item('b'); - ctx.instance.selectItem(itemA); - ctx.instance.selectItem(itemB); - - expect(itemA.$el.hasAttribute('data-active')).toBe(false); - expect(itemA.$el.hasAttribute('aria-current')).toBe(false); - expect(itemB.$el.hasAttribute('data-active')).toBe(true); - expect(itemB.$el.getAttribute('aria-current')).toBe('true'); - }); - - it('clears the active state and emits deselect on deselect()', async () => { - const ctx = createStoreLocator([{ id: 'a', lngLat: [1, 1] }]); - await mountAndLoad(ctx); - - const deselect = vi.fn(); - ctx.instance.$on('deselect', deselect); - - const itemA = ctx.item('a'); - ctx.instance.selectItem(itemA); - ctx.instance.deselect(); - - expect(itemA.$el.hasAttribute('data-active')).toBe(false); - expect(itemA.$el.hasAttribute('aria-current')).toBe(false); - expect(deselect).toHaveBeenCalledTimes(1); - expect((ctx.instance as any).__selected).toBeUndefined(); - }); - - it('clears the internal selection when the selected item is unregistered', async () => { - const ctx = createStoreLocator([{ id: 'a', lngLat: [1, 1] }]); - await mountAndLoad(ctx); - - const itemA = ctx.item('a'); - ctx.instance.selectItem(itemA); - expect((ctx.instance as any).__selected).toBe(itemA); - - ctx.instance.unregisterItem(itemA); - expect((ctx.instance as any).__selected).toBeUndefined(); - }); - }); - - // --- 6. Cluster feature-click -> select by id ---------------------------- - describe('cluster feature-click -> select by id', () => { - it('selects the item whose feature id matches a string id', async () => { - const ctx = createStoreLocator([ - { id: 'a', lngLat: [1, 1] }, - { id: 'b', lngLat: [2, 2] }, - ]); - await mountAndLoad(ctx); - - const select = vi.fn(); - ctx.instance.$on('select', select); - - ctx.fireFeatureClick({ properties: { id: 'b' } }); - - const itemB = ctx.item('b'); - expect(itemB.$el.hasAttribute('data-active')).toBe(true); - expect(ctx.mockMap.flyTo).toHaveBeenCalledWith({ center: [2, 2], zoom: 14 }); - expect(select.mock.calls.at(-1)?.[0].detail[0]).toBe(itemB); - }); - - it('coerces a numeric feature id with String() before matching', async () => { - const ctx = createStoreLocator([{ id: '42', lngLat: [9, 9] }]); - await mountAndLoad(ctx); - - const select = vi.fn(); - ctx.instance.$on('select', select); - - // Numeric id must still match the string id '42' via String(id). - ctx.fireFeatureClick({ properties: { id: 42 } }); - - expect(ctx.item('42').$el.hasAttribute('data-active')).toBe(true); - expect(select).toHaveBeenCalledTimes(1); - }); - - it('does nothing (no select, no throw) for an unknown feature id', async () => { - const ctx = createStoreLocator([{ id: 'a', lngLat: [1, 1] }]); - await mountAndLoad(ctx); - - const select = vi.fn(); - ctx.instance.$on('select', select); - - expect(() => ctx.fireFeatureClick({ properties: { id: 'nope' } })).not.toThrow(); - expect(select).not.toHaveBeenCalled(); - }); - - it('does nothing when the feature or its id is missing', async () => { - const ctx = createStoreLocator([{ id: 'a', lngLat: [1, 1] }]); - await mountAndLoad(ctx); - - const select = vi.fn(); - ctx.instance.$on('select', select); - - expect(() => ctx.fireFeatureClick(undefined)).not.toThrow(); - expect(() => ctx.fireFeatureClick({ properties: {} })).not.toThrow(); - expect(() => ctx.fireFeatureClick({})).not.toThrow(); - expect(select).not.toHaveBeenCalled(); - }); - }); - - // --- 6b. Deferred cluster wiring (async mount timing) -------------------- - describe('deferred cluster wiring', () => { - it('does not wire the cluster until it is mounted, then wires it', async () => { - const ctx = createStoreLocator([ - { id: 'a', lngLat: [1, 1] }, - { id: 'b', lngLat: [2, 2] }, - ]); - // The MapboxCluster is an async child of the map: it is queryable before - // its `mounted()` hook adds the GeoJSON source. Simulate that window. - ctx.mockCluster.$isMounted = false; - await mountAndLoad(ctx); - - const select = vi.fn(); - ctx.instance.$on('select', select); - - // Not wired yet: a feature-click is ignored because pushing/wiring before - // the source exists would silently no-op. - expect((ctx.instance as any).__clusterWired).toBe(false); - ctx.fireFeatureClick({ properties: { id: 'b' } }); - expect(select).not.toHaveBeenCalled(); - - // The cluster finishes mounting (source now added): wiring can proceed. - ctx.mockCluster.$isMounted = true; - (ctx.instance as any).__wireChildren(); - - expect((ctx.instance as any).__clusterWired).toBe(true); - ctx.fireFeatureClick({ properties: { id: 'b' } }); - expect(select).toHaveBeenCalledTimes(1); - expect(ctx.mockCluster.setData).toHaveBeenCalled(); - }); - - it('keeps polling for the geocoder even after the cluster is wired first', async () => { - // The cluster is mounted and queryable immediately, but the geocoder only - // becomes queryable a couple of ticks later. The coordinator must poll - // until BOTH children are wired — stopping as soon as the cluster is wired - // would leave the geocoder's `result` listener unattached. - const ctx = createStoreLocator([{ id: 'a', lngLat: [1, 1] }], { geocoderReadyAfter: 2 }); - await mountAndLoad(ctx); - - // The cluster wired on the first attempt... - expect((ctx.instance as any).__clusterWired).toBe(true); - // ...and the poll continued until the late geocoder was wired too. - expect((ctx.instance as any).__geocoderWired).toBe(true); - - // Proof the geocoder listener is live: a result frames the map. - ctx.mockMap.fitBounds.mockClear(); - ctx.fireGeocoderResult({ bbox: [10, 20, 30, 40] }); - expect(ctx.mockMap.fitBounds).toHaveBeenCalledWith([ - [10, 20], - [30, 40], - ]); - }); - }); - - // --- 7. Geocoder result -------------------------------------------------- - describe('geocoder result', () => { - it('fits the map to the bbox when the result has one', async () => { - const ctx = createStoreLocator([{ id: 'a', lngLat: [1, 1] }], { geocoder: true }); - await mountAndLoad(ctx); - - ctx.mockMap.fitBounds.mockClear(); - ctx.fireGeocoderResult({ bbox: [10, 20, 30, 40] }); - - expect(ctx.mockMap.fitBounds).toHaveBeenCalledWith([ - [10, 20], - [30, 40], - ]); - }); - - it('flies to the center when the result has no bbox', async () => { - const ctx = createStoreLocator([{ id: 'a', lngLat: [1, 1] }], { geocoder: true }); - await mountAndLoad(ctx); - - ctx.mockMap.flyTo.mockClear(); - ctx.fireGeocoderResult({ center: [5, 6] }); - - expect(ctx.mockMap.flyTo).toHaveBeenCalledWith({ center: [5, 6] }); - }); - - it('does nothing and does not throw on a missing/empty result', async () => { - const ctx = createStoreLocator([{ id: 'a', lngLat: [1, 1] }], { geocoder: true }); - await mountAndLoad(ctx); - - ctx.mockMap.fitBounds.mockClear(); - ctx.mockMap.flyTo.mockClear(); - - expect(() => ctx.fireGeocoderResult(undefined)).not.toThrow(); - expect(() => ctx.fireGeocoderResult(null)).not.toThrow(); - expect(() => ctx.fireGeocoderResult({})).not.toThrow(); - expect(ctx.mockMap.fitBounds).not.toHaveBeenCalled(); - expect(ctx.mockMap.flyTo).not.toHaveBeenCalled(); - }); - }); - - // --- 8. fitOnUpdate ------------------------------------------------------ - describe('fitOnUpdate', () => { - it('fits the map to the item extent on an item-set change when enabled', async () => { - const ctx = createStoreLocator( - [ - { id: 'a', lngLat: [1, 2] }, - { id: 'b', lngLat: [3, 4] }, - { id: 'c', lngLat: [5, 0] }, - ], - { attrs: { 'data-option-fit-on-update': '' } }, - ); - await mountAndLoad(ctx); - - expect(ctx.mockMap.fitBounds).toHaveBeenCalledWith( - [ - [1, 0], - [5, 4], - ], - { padding: 40 }, - ); - }); - - it('does not fit the map on an item-set change when disabled', async () => { - const ctx = createStoreLocator([ - { id: 'a', lngLat: [1, 2] }, - { id: 'b', lngLat: [3, 4] }, - ]); - await mountAndLoad(ctx); - - expect(ctx.mockMap.fitBounds).not.toHaveBeenCalled(); - }); - }); - - // --- 8b. Single initial sync (no duplicate on load) ---------------------- - describe('initial load sync', () => { - it('runs the initial sync exactly ONCE when the cluster is already mounted at load', async () => { - // Regression: with the cluster mounted before `map-load`, `__wireChildren` - // used to also sync when wiring it, so `__handleMapLoad`'s own sync made the - // first `setData`, `fitBounds` and `filter` emission all fire TWICE. Each - // must happen exactly once. - const ctx = createStoreLocator( - [ - { id: 'a', lngLat: [1, 2] }, - { id: 'b', lngLat: [3, 4] }, - ], - { attrs: { 'data-option-fit-on-update': '' } }, - ); - - const filter = vi.fn(); - - vi.useFakeTimers(); - ctx.instance.$mount(); - // Let the mount-time registration debounce settle *before* load: it runs - // while `isLoaded` is still false, so it no-ops and cannot pollute the - // post-load counts. This isolates the wiring-vs-handleMapLoad double sync. - await vi.advanceTimersByTimeAsync(200); - expect(ctx.mockCluster.setData).not.toHaveBeenCalled(); - - // Listen before the load so the very first `filter` emission is counted. - ctx.instance.$on('filter', filter); - ctx.fireLoad(); - await vi.advanceTimersByTimeAsync(200); - vi.useRealTimers(); - - expect(ctx.mockCluster.setData).toHaveBeenCalledTimes(1); - expect(ctx.mockMap.fitBounds).toHaveBeenCalledTimes(1); - expect(filter).toHaveBeenCalledTimes(1); - }); - }); - - // --- 9. Lifecycle -------------------------------------------------------- - describe('lifecycle teardown', () => { - it('detaches listeners and clears the registry on destroy', async () => { - const ctx = createStoreLocator( - [ - { id: 'a', lngLat: [1, 1] }, - { id: 'b', lngLat: [2, 2] }, - ], - { geocoder: true }, - ); - await mountAndLoad(ctx); - - const filter = vi.fn(); - const select = vi.fn(); - ctx.instance.$on('filter', filter); - ctx.instance.$on('select', select); - - vi.useFakeTimers(); - ctx.instance.$destroy(); - await vi.advanceTimersByTimeAsync(100); - vi.useRealTimers(); - - // Registry cleared. - expect((ctx.instance as any).__items).toEqual([]); - expect((ctx.instance as any).__selected).toBeUndefined(); - expect(ctx.instance.isLoaded).toBe(false); - - // Detached listeners: subsequent events are inert. - ctx.fireMoveEnd(); - ctx.fireFeatureClick({ properties: { id: 'a' } }); - ctx.mockMap.fitBounds.mockClear(); - ctx.mockMap.flyTo.mockClear(); - ctx.fireGeocoderResult({ center: [1, 1] }); - - expect(filter).not.toHaveBeenCalled(); - expect(select).not.toHaveBeenCalled(); - expect(ctx.mockMap.flyTo).not.toHaveBeenCalled(); - expect(ctx.mockMap.fitBounds).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/tests/MapboxMap/StoreLocatorItem.spec.ts b/packages/tests/MapboxMap/StoreLocatorItem.spec.ts deleted file mode 100644 index 0d41d602..00000000 --- a/packages/tests/MapboxMap/StoreLocatorItem.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -// Importing the mock first registers the `mapbox-gl` module mock before the -// package (and its real `mapbox-gl` dependency) is imported below. -import './mock-mapbox-gl.js'; -import { h } from '#test-utils'; -import { StoreLocatorItem } from '@studiometa/ui-mapbox'; - -/** - * A minimal coordinator stand-in exposing only the surface the item touches. - */ -function fakeCoordinator() { - return { - registerItem: vi.fn(), - unregisterItem: vi.fn(), - selectItem: vi.fn(), - }; -} - -describe('StoreLocatorItem component', () => { - it('registers with the coordinator on mount', () => { - const el = h('li', { - 'data-component': 'StoreLocatorItem', - 'data-option-id': 'a', - 'data-option-lng-lat': '[1,2]', - }) as HTMLElement; - const item = new StoreLocatorItem(el); - const coordinator = fakeCoordinator(); - Object.defineProperty(item, 'storeLocator', { get: () => coordinator, configurable: true }); - - item.mounted(); - - expect(coordinator.registerItem).toHaveBeenCalledWith(item); - }); - - it('unregisters on destroy even after the element has been detached', () => { - const el = h('li', { - 'data-component': 'StoreLocatorItem', - 'data-option-id': 'a', - 'data-option-lng-lat': '[1,2]', - }) as HTMLElement; - const item = new StoreLocatorItem(el); - const coordinator = fakeCoordinator(); - - // The coordinator resolves via `$closest` while the item is connected… - let resolved: ReturnType | undefined = coordinator; - Object.defineProperty(item, 'storeLocator', { get: () => resolved, configurable: true }); - - item.mounted(); - expect(coordinator.registerItem).toHaveBeenCalledWith(item); - - // …but a `Fetch`/facet swap detaches the node first, so `$closest` (and thus - // the getter) would now return nothing. The cached reference must keep the - // unregister path working. - resolved = undefined; - - item.destroyed(); - - expect(coordinator.unregisterItem).toHaveBeenCalledWith(item); - }); -}); diff --git a/packages/tests/MapboxMap/exports.spec.ts b/packages/tests/MapboxMap/exports.spec.ts index 937919d6..8d8f4851 100644 --- a/packages/tests/MapboxMap/exports.spec.ts +++ b/packages/tests/MapboxMap/exports.spec.ts @@ -12,6 +12,7 @@ test('@studiometa/ui-mapbox exports', () => { "AbstractMapboxControl", "AbstractMapboxMapChild", "MapboxCluster", + "MapboxClusterItem", "MapboxFullscreenControl", "MapboxGeocoder", "MapboxGeolocateControl", @@ -23,8 +24,7 @@ test('@studiometa/ui-mapbox exports', () => { "MapboxNavigationControl", "MapboxPopup", "MapboxSource", - "StoreLocator", - "StoreLocatorItem", + "registerMapboxComponents", ] `); diff --git a/packages/tests/MapboxMap/subpath-exports.spec.ts b/packages/tests/MapboxMap/subpath-exports.spec.ts index a4384fed..1547f90a 100644 --- a/packages/tests/MapboxMap/subpath-exports.spec.ts +++ b/packages/tests/MapboxMap/subpath-exports.spec.ts @@ -4,9 +4,9 @@ import { test, expect } from 'vitest'; import { MockMap } from './mock-mapbox-gl.js'; import * as barrel from '@studiometa/ui-mapbox'; import MapboxMapDefault, { MapboxMap as MapboxMapNamed } from '@studiometa/ui-mapbox/MapboxMap'; -import StoreLocatorDefault, { - StoreLocator as StoreLocatorNamed, -} from '@studiometa/ui-mapbox/StoreLocator'; +import MapboxClusterItemDefault, { + MapboxClusterItem as MapboxClusterItemNamed, +} from '@studiometa/ui-mapbox/MapboxClusterItem'; import MapboxClusterDefault, { MapboxCluster as MapboxClusterNamed, } from '@studiometa/ui-mapbox/MapboxCluster'; @@ -15,16 +15,16 @@ import MapboxClusterDefault, { import MapboxMapJsDefault, { MapboxMap as MapboxMapJsNamed, } from '@studiometa/ui-mapbox/MapboxMap.js'; -import StoreLocatorJsDefault, { - StoreLocator as StoreLocatorJsNamed, -} from '@studiometa/ui-mapbox/StoreLocator.js'; +import MapboxClusterItemJsDefault, { + MapboxClusterItem as MapboxClusterItemJsNamed, +} from '@studiometa/ui-mapbox/MapboxClusterItem.js'; import MapboxClusterJsDefault, { MapboxCluster as MapboxClusterJsNamed, } from '@studiometa/ui-mapbox/MapboxCluster.js'; test.each([ ['MapboxMap', MapboxMapDefault, MapboxMapNamed, barrel.MapboxMap], - ['StoreLocator', StoreLocatorDefault, StoreLocatorNamed, barrel.StoreLocator], + ['MapboxClusterItem', MapboxClusterItemDefault, MapboxClusterItemNamed, barrel.MapboxClusterItem], ['MapboxCluster', MapboxClusterDefault, MapboxClusterNamed, barrel.MapboxCluster], ])('%s is available at its own subpath as default and named export', (_name, def, named, fromBarrel) => { // Ensure the `mapbox-gl` mock is registered before the package is imported. @@ -38,7 +38,12 @@ test.each([ test.each([ ['MapboxMap', MapboxMapJsDefault, MapboxMapJsNamed, barrel.MapboxMap], - ['StoreLocator', StoreLocatorJsDefault, StoreLocatorJsNamed, barrel.StoreLocator], + [ + 'MapboxClusterItem', + MapboxClusterItemJsDefault, + MapboxClusterItemJsNamed, + barrel.MapboxClusterItem, + ], ['MapboxCluster', MapboxClusterJsDefault, MapboxClusterJsNamed, barrel.MapboxCluster], ])( '%s is available at its `.js`-extensioned subpath as default and named export', diff --git a/packages/ui-mapbox/AbstractMapboxControl.ts b/packages/ui-mapbox/AbstractMapboxControl.ts index afa8df1d..85adc583 100644 --- a/packages/ui-mapbox/AbstractMapboxControl.ts +++ b/packages/ui-mapbox/AbstractMapboxControl.ts @@ -76,7 +76,9 @@ export class AbstractMapboxControl extends Abst * Mounted hook. */ mounted() { - this.map?.addControl(this.control, this.$options.position); + this.whenMapReady((map) => { + map.addControl(this.control, this.$options.position); + }); } /** @@ -84,9 +86,10 @@ export class AbstractMapboxControl extends Abst */ destroyed() { if (this.__control) { - this.map?.removeControl(this.__control); + this.__readyMap?.removeControl(this.__control); this.__control = undefined; } + super.destroyed(); } } diff --git a/packages/ui-mapbox/AbstractMapboxMapChild.ts b/packages/ui-mapbox/AbstractMapboxMapChild.ts index c03bfd81..91e0d3e4 100644 --- a/packages/ui-mapbox/AbstractMapboxMapChild.ts +++ b/packages/ui-mapbox/AbstractMapboxMapChild.ts @@ -1,4 +1,5 @@ import { Base, type BaseProps } from '@studiometa/js-toolkit'; +import type { Map } from 'mapbox-gl'; import type { MapboxMap } from './MapboxMap.js'; export interface AbstractMapboxMapChildProps extends BaseProps {} @@ -6,15 +7,45 @@ export interface AbstractMapboxMapChildProps extends BaseProps {} /** * Base class for every component living inside a `MapboxMap`. * - * It resolves the closest parent `MapboxMap` component via `$closest` and - * exposes its Mapbox `Map` instance so children (markers, popups, controls, - * layers, ...) can register themselves against it. + * Children are self-sufficient and dynamic-DOM-native: they resolve their + * parent `MapboxMap` on their own via `$closest`, wait for the map to be ready + * with `whenMapReady`, then inject their contribution. Because they are + * registered globally (see `registerMapboxComponents`), js-toolkit's document + * wide `MutationObserver` mounts them whenever their element enters the DOM — + * statically, `Fetch`-injected or `appendChild`-ed — and terminates them when it + * leaves, at which point they remove their contribution again. * * @see https://ui.studiometa.dev/-/components/MapboxMap/ */ export class AbstractMapboxMapChild extends Base< T & AbstractMapboxMapChildProps > { + /** + * The parent `MapboxMap` resolved at ready-time. + * + * `destroyed()` runs *after* the element has been detached from the DOM (e.g. + * a `Fetch` list swap, or the parent map itself being removed), and a + * `$closest` lookup on a disconnected node returns nothing — a real bug that + * would leave the child's contribution stuck on the map. Caching the resolved + * references at ready-time keeps every teardown path working through the + * detach. + * @private + */ + __readyMapboxMap?: MapboxMap; + + /** + * The Mapbox `Map` instance resolved at ready-time, cached for teardown. + * @private + */ + __readyMap?: Map; + + /** + * Off handler for a still-pending `map-load` subscription, flushed on destroy + * so a child removed before the map finished loading leaves no listener behind. + * @private + */ + __offMapReady?: () => void; + /** * The closest parent `MapboxMap` component instance. */ @@ -36,6 +67,63 @@ export class AbstractMapboxMapChild extends Bas get map() { return this.mapboxMap?.map; } + + /** + * Run a callback once the parent map is ready. + * + * Resolves the closest parent `MapboxMap`; if its map is already loaded the + * callback runs synchronously, otherwise it runs once on the map's `map-load`. + * The callback never fires after the child has been destroyed, and the + * resolved map/`MapboxMap` are cached before it runs so teardown can reach + * them even once the element is detached. + * + * @param {(map: Map) => void} cb The work to run against the ready map. + */ + whenMapReady(cb: (map: Map) => void): void { + const mapboxMap = this.$closest('MapboxMap'); + + if (!mapboxMap) { + this.$warn( + 'Can not find the parent map, does this component has a parent MapboxMap component?', + ); + return; + } + + const run = () => { + // The child may have been destroyed while waiting for the map to load: do + // not inject anything into a map the child no longer belongs to. + if (!this.$isMounted) { + return; + } + + this.__readyMapboxMap = mapboxMap; + this.__readyMap = mapboxMap.map; + cb(this.__readyMap); + }; + + if (mapboxMap.isLoaded) { + run(); + } else { + this.__offMapReady = mapboxMap.$on( + 'map-load', + () => { + this.__offMapReady = undefined; + run(); + }, + { once: true }, + ); + } + } + + /** + * Destroyed hook: flush any still-pending `map-load` subscription. + * + * Subclasses overriding `destroyed()` must call `super.destroyed()`. + */ + destroyed() { + this.__offMapReady?.(); + this.__offMapReady = undefined; + } } export default AbstractMapboxMapChild; diff --git a/packages/ui-mapbox/MapboxCluster.ts b/packages/ui-mapbox/MapboxCluster.ts index 7f7b5da9..22f39fcb 100644 --- a/packages/ui-mapbox/MapboxCluster.ts +++ b/packages/ui-mapbox/MapboxCluster.ts @@ -1,4 +1,6 @@ import { type BaseProps, type BaseConfig } from '@studiometa/js-toolkit'; +import { debounce } from '@studiometa/js-toolkit/utils'; +import mapboxgl from 'mapbox-gl'; import type { CircleLayerSpecification, SymbolLayerSpecification, @@ -8,11 +10,15 @@ import type { GeoJSONSource, MapMouseEvent, LngLatLike, + LngLatBoundsLike, + Popup, } from 'mapbox-gl'; +import type { FeatureCollection, Point } from 'geojson'; import { AbstractMapboxMapChild, type AbstractMapboxMapChildProps, } from './AbstractMapboxMapChild.js'; +import type { MapboxClusterItem } from './MapboxClusterItem.js'; /** * Module level counter used to generate a unique base id per instance. @@ -30,11 +36,7 @@ function nextClusterId(): string { } export interface MapboxClusterProps extends AbstractMapboxMapChildProps { - $refs: { - geojson?: HTMLScriptElement; - }; $options: { - data: string; clusterMaxZoom: number; clusterRadius: number; clusterMinPoints: number; @@ -46,11 +48,33 @@ export interface MapboxClusterProps extends AbstractMapboxMapChildProps { unclusteredPointLayerType: string; unclusteredPointLayout: Record; unclusteredPointPaint: Record; + itemZoomLevel: number; + noSort: boolean; + fitOnUpdate: boolean; + popupOptions: Record; }; } /** - * Display a clustered GeoJSON source on the map. + * A clustered GeoJSON source whose features ARE its rendered items. + * + * `MapboxCluster` merges the map source and the sidebar list of a classic + * "store locator" into a single declarative unit: `MapboxClusterItem`s (rendered + * list entries living outside the map) push themselves into the cluster's + * registry, and the cluster derives its clustered GeoJSON source from that + * registry. There is no separate `StoreLocator` coordinator and nothing observes + * or `$query`s — items self-register, the cluster rebuilds (debounced) on every + * registry change, gated on `whenMapReady`. + * + * Each item has three independent states: + * + * 1. **Registered** — the item exists in the DOM. Drives the **map data** and + * only changes when the item set changes (e.g. a `Fetch` swaps the list). + * 2. **In bounds** — the item's `lngLat` is inside the current viewport. Drives + * **list visibility + distance sort only**, recomputed on map `moveend`. + * 3. **Selected** — the chosen item. Drives fly-to, the popup, `active` styling + * and the `select` event. + * * @see https://ui.studiometa.dev/-/components/MapboxMap/ */ export class MapboxCluster extends AbstractMapboxMapChild< @@ -61,15 +85,8 @@ export class MapboxCluster extends AbstractMapb */ static config: BaseConfig = { name: 'MapboxCluster', - refs: ['geojson'], - emits: ['cluster-click', 'feature-click', 'feature-mouseenter', 'feature-mouseleave'], + emits: ['cluster-click', 'feature-click', 'select', 'deselect', 'filter'], options: { - // js-toolkit options do not support union types, so `data` is declared as - // a String and only accepts the URL of a `.geojson` file. To pass inline - // GeoJSON declaratively, provide it via the `geojson` script ref instead, - // a ` + ``` +The cluster reports a click on an unclustered point through its `item-click` event but never selects or flies on its own. To turn this into a full "find a store near you" experience — selection, popups, viewport filtering and address search — wrap the cluster in a [`StoreLocator`](/components/StoreLocator/) orchestrator. + ## Listening to map events The `MapboxMap` component re-emits the Mapbox map events. Listen to them from a parent component by defining `on` methods — for example `onMapboxMapClick` or `onMapboxMapMapLoad` for the custom `map-load` event. diff --git a/packages/docs/components/MapboxMap/js-api.md b/packages/docs/components/MapboxMap/js-api.md index f1c66eaf..7974e62a 100644 --- a/packages/docs/components/MapboxMap/js-api.md +++ b/packages/docs/components/MapboxMap/js-api.md @@ -13,7 +13,7 @@ You only ever register `MapboxMap` with [`registerComponent`](https://js-toolkit - **[Markers & Popups](#markers-popups)** — `MapboxMarker`, `MapboxPopup` - **[Controls](#controls)** — `MapboxNavigationControl`, `MapboxGeolocateControl`, `MapboxFullscreenControl`, `MapboxGeocoder` - **[Data](#data)** — `MapboxSource`, `MapboxLayer`, `MapboxImage`, `MapboxImages` -- **[Cluster](#cluster)** — `MapboxCluster` +- **[Cluster](#cluster)** — `MapboxCluster`, `MapboxClusterItem` (see also the [`StoreLocator`](/components/StoreLocator/) orchestrator) - **[AbstractMapboxMapChild](#abstractmapboxmapchild)** — the shared base class ## Reactivity and updates @@ -312,43 +312,84 @@ Load and register a list of images against the map sprite in one component. ### MapboxCluster -Display a clustered GeoJSON source. The component sets up the clustered source, the cluster circles layer, the cluster count labels layer and the unclustered points layer, together with the click-to-zoom interaction on clusters and pointer feedback on features. +A clustered GeoJSON **source driver** whose features ARE its rendered items. The `MapboxClusterItem`s living in its subtree self-register, and the cluster derives its clustered GeoJSON source from that registry — the same markup drives both a sidebar list and the clustered points on the map. It sets up the clustered source, the cluster circles layer, the cluster count labels layer and the unclustered points layer, together with the click-to-zoom interaction on clusters and pointer feedback on features. -::: tip Inline GeoJSON via the `geojson` ref -js-toolkit options do not support union types, so the `data` option is declared as a `String` and only accepts the **URL of a `.geojson` file**. To pass inline GeoJSON declaratively, add a ` +
    + {% for point in points %} +
  • + {% endfor %} +
diff --git a/packages/docs/components/StoreLocator/examples.md b/packages/docs/components/StoreLocator/examples.md index 342bedb0..1a8feeef 100644 --- a/packages/docs/components/StoreLocator/examples.md +++ b/packages/docs/components/StoreLocator/examples.md @@ -4,19 +4,19 @@ title: StoreLocator examples # Examples -Both examples register a single root component that declares the `StoreLocator` — its `MapboxMap` and `StoreLocatorItem` children are resolved automatically, and the `MapboxCluster` inside the map is fed by the coordinator once the map has loaded. Each example loads the [Mapbox GL stylesheet](/components/MapboxMap/#installation) from a CDN and picks a Mapbox style through the `map-options` option. Replace the access token with your own [access token](https://docs.mapbox.com/help/getting-started/access-tokens/); the token used here is a public, restricted demo token. +Both examples register the whole Mapbox family with `registerMapboxComponents` and add a small root component for the detail panel. The `StoreLocator` orchestrates a `MapboxMap` containing a `MapboxCluster` whose `MapboxClusterItem`s are the sidebar entries. Each example loads the [Mapbox GL stylesheet](/components/MapboxMap/#installation) from a CDN and picks a Mapbox style through the `map-options` option. Replace the access token with your own [access token](https://docs.mapbox.com/help/getting-started/access-tokens/); the token used here is a public, restricted demo token. ## Basic store locator -A `StoreLocator` wrapping a sidebar list of Paris stores and a clustered map. Note that the `MapboxCluster` has **no** authored data: the coordinator derives a GeoJSON `FeatureCollection` from the list items and pushes it to the cluster after the map loads. +A `StoreLocator` wrapping a clustered map whose `MapboxCluster` holds a sidebar list of Paris `MapboxClusterItem`s. The cluster has **no** authored data: it derives a GeoJSON `FeatureCollection` from its registered items, and the orchestrator adds selection, viewport filtering and the detail drawer on top. Try it out: - **Pan or zoom the map** — the sidebar filters to the in-view stores and reorders them nearest-first. -- **Click a store in the list** — the map flies there, the item is marked active and the detail drawer opens. +- **Click a store in the list** — the map flies there, the item is marked active, a popup opens and the detail drawer opens. - **Click a cluster** — the map zooms in and splits it; click an individual pin to select its store. -The detail panel is the integrator's choice. Here it is a [`Dialog`](/components/Dialog/) drawer, opened from the [`select`](./js-api#select) event through a small root component's `onStoreLocatorSelect` handler, which copies the selected item's `