@elabs-ai/components-maps 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/dist/index.css +11 -0
  4. package/dist/index.css.map +1 -0
  5. package/dist/index.d.ts +442 -0
  6. package/dist/index.js +1475 -0
  7. package/dist/index.js.map +1 -0
  8. package/package.json +66 -0
  9. package/src/index.ts +37 -0
  10. package/src/lib/arc-math.test.ts +41 -0
  11. package/src/lib/arc-math.ts +45 -0
  12. package/src/lib/merge-hover-paint.test.ts +36 -0
  13. package/src/lib/merge-hover-paint.ts +21 -0
  14. package/src/lib/use-token-color.ts +23 -0
  15. package/src/map-arc/index.ts +9 -0
  16. package/src/map-arc/map-arc.stories.tsx +58 -0
  17. package/src/map-arc/map-arc.tsx +294 -0
  18. package/src/map-canvas/index.ts +8 -0
  19. package/src/map-canvas/map-canvas-webgl-fallback.test.tsx +30 -0
  20. package/src/map-canvas/map-canvas.stories.tsx +54 -0
  21. package/src/map-canvas/map-canvas.test.tsx +93 -0
  22. package/src/map-canvas/map-canvas.tsx +349 -0
  23. package/src/map-canvas/map-context.ts +32 -0
  24. package/src/map-canvas/maps.css +15 -0
  25. package/src/map-canvas/use-resolved-basemap-theme.ts +77 -0
  26. package/src/map-cluster-layer/index.ts +1 -0
  27. package/src/map-cluster-layer/map-cluster-layer.stories.tsx +56 -0
  28. package/src/map-cluster-layer/map-cluster-layer.tsx +292 -0
  29. package/src/map-controls/index.ts +1 -0
  30. package/src/map-controls/map-controls.stories.tsx +43 -0
  31. package/src/map-controls/map-controls.test.tsx +56 -0
  32. package/src/map-controls/map-controls.tsx +220 -0
  33. package/src/map-geojson/index.ts +9 -0
  34. package/src/map-geojson/map-geojson.stories.tsx +124 -0
  35. package/src/map-geojson/map-geojson.tsx +274 -0
  36. package/src/map-marker/index.ts +12 -0
  37. package/src/map-marker/map-marker.stories.tsx +71 -0
  38. package/src/map-marker/map-marker.test.tsx +81 -0
  39. package/src/map-marker/map-marker.tsx +373 -0
  40. package/src/map-popup/index.ts +1 -0
  41. package/src/map-popup/map-popup.tsx +113 -0
  42. package/src/map-route/index.ts +1 -0
  43. package/src/map-route/map-route.stories.tsx +56 -0
  44. package/src/map-route/map-route.tsx +143 -0
  45. package/src/test-utils/maplibre-mock.ts +249 -0
@@ -0,0 +1,124 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { resolveTokenColor } from "@elabs-ai/components-tokens";
4
+
5
+ import { MapCanvas } from "../map-canvas";
6
+ import { MapGeoJSON } from "./map-geojson";
7
+
8
+ /**
9
+ * Theme-reactive token resolution for CONSUMER-side paint (outside the map
10
+ * context). A one-shot `resolveTokenColor` at render time can read the `:root`
11
+ * fallback: Storybook's theme decorator stamps `data-theme` after the first
12
+ * render (the sweep caught the blue `:root` primary instead of the active
13
+ * theme's own).
14
+ * Re-resolve whenever the theme attribute changes — the same contract the
15
+ * package's internal `useTokenColor` follows.
16
+ */
17
+ function useThemedTokenColor(name: string): string {
18
+ const [color, setColor] = useState("#000000");
19
+ useEffect(() => {
20
+ const update = () => setColor(resolveTokenColor(name));
21
+ update();
22
+ const observer = new MutationObserver(update);
23
+ observer.observe(document.documentElement, {
24
+ attributes: true,
25
+ attributeFilter: ["data-theme", "class"],
26
+ });
27
+ return () => observer.disconnect();
28
+ }, [name]);
29
+ return color;
30
+ }
31
+
32
+ type RegionProps = { name: string; value: number };
33
+
34
+ // A synthetic "regions" grid (offline-friendly stand-in for country shapes).
35
+ function makeRegions(): GeoJSON.FeatureCollection<GeoJSON.Polygon, RegionProps> {
36
+ const features: GeoJSON.Feature<GeoJSON.Polygon, RegionProps>[] = [];
37
+ let i = 0;
38
+ for (let row = 0; row < 4; row += 1) {
39
+ for (let col = 0; col < 6; col += 1) {
40
+ const x = -12 + col * 8;
41
+ const y = 38 + row * 5;
42
+ i += 1;
43
+ features.push({
44
+ type: "Feature",
45
+ id: `r${i}`,
46
+ properties: { name: `Region ${i}`, value: ((i * 37) % 100) / 100 },
47
+ geometry: {
48
+ type: "Polygon",
49
+ coordinates: [
50
+ [
51
+ [x, y],
52
+ [x + 7.4, y],
53
+ [x + 7.4, y + 4.4],
54
+ [x, y + 4.4],
55
+ [x, y],
56
+ ],
57
+ ],
58
+ },
59
+ });
60
+ }
61
+ }
62
+ return { type: "FeatureCollection", features };
63
+ }
64
+
65
+ const regions = makeRegions();
66
+
67
+ const meta = {
68
+ title: "Maps/MapGeoJSON",
69
+ component: MapGeoJSON,
70
+ tags: ["autodocs"],
71
+ parameters: { layout: "fullscreen" },
72
+ } satisfies Meta<typeof MapGeoJSON>;
73
+ export default meta;
74
+ type Story = StoryObj<typeof meta>;
75
+
76
+ /** Neutral fills + hairline outlines from the theme tokens, on a blank canvas. */
77
+ export const Default: Story = {
78
+ render: () => (
79
+ <div className="h-[480px]">
80
+ <MapCanvas blank center={[10, 48]} zoom={3.2}>
81
+ <MapGeoJSON data={regions} />
82
+ </MapCanvas>
83
+ </div>
84
+ ),
85
+ };
86
+
87
+ function ChoroplethDemo() {
88
+ const [hovered, setHovered] = useState<RegionProps | null>(null);
89
+ // WebGL paint can't read CSS variables — resolve the token, re-resolving on
90
+ // theme change (the same mechanism the package uses for its own defaults).
91
+ const primary = useThemedTokenColor("--primary");
92
+ return (
93
+ <div className="relative h-[480px]">
94
+ <MapCanvas blank center={[10, 48]} zoom={3.2}>
95
+ <MapGeoJSON<RegionProps>
96
+ data={regions}
97
+ promoteId="name"
98
+ interactive
99
+ fillPaint={{
100
+ "fill-color": primary,
101
+ "fill-opacity": [
102
+ "interpolate",
103
+ ["linear"],
104
+ ["get", "value"],
105
+ 0,
106
+ 0.15,
107
+ 1,
108
+ 0.95,
109
+ ] as never,
110
+ }}
111
+ fillHoverPaint={{ "fill-opacity": 1 }}
112
+ onHover={(e) => setHovered(e?.feature.properties ?? null)}
113
+ />
114
+ </MapCanvas>
115
+ <div className="absolute top-2 left-2 rounded-md border bg-card px-3 py-2 text-caption text-card-foreground shadow-sm">
116
+ {hovered ? `${hovered.name}: ${(hovered.value * 100).toFixed(0)}%` : "Hover a region…"}
117
+ </div>
118
+ </div>
119
+ );
120
+ }
121
+
122
+ export const InteractiveHover: Story = {
123
+ render: () => <ChoroplethDemo />,
124
+ };
@@ -0,0 +1,274 @@
1
+ "use client";
2
+
3
+ import type MapLibreGL from "maplibre-gl";
4
+ import { useEffect, useId, useMemo, useRef } from "react";
5
+
6
+ import { useMap } from "../map-canvas/map-context";
7
+ import { mergeHoverPaint } from "../lib/merge-hover-paint";
8
+ import { useTokenColor } from "../lib/use-token-color";
9
+
10
+ export type MapGeoJSONData<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> =
11
+ | GeoJSON.FeatureCollection<GeoJSON.Geometry, P>
12
+ | GeoJSON.Feature<GeoJSON.Geometry, P>
13
+ | GeoJSON.Geometry
14
+ | string;
15
+
16
+ export type MapFillPaint = NonNullable<MapLibreGL.FillLayerSpecification["paint"]>;
17
+ export type MapLinePaint = NonNullable<MapLibreGL.LineLayerSpecification["paint"]>;
18
+
19
+ /** A rendered feature with strongly-typed `properties`. */
20
+ export type MapGeoJSONFeature<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> =
21
+ Omit<MapLibreGL.MapGeoJSONFeature, "properties"> & { properties: P };
22
+
23
+ /** Event payload passed to MapGeoJSON interaction callbacks. */
24
+ export type MapGeoJSONEvent<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> = {
25
+ /** The feature under the cursor, with its typed GeoJSON properties. */
26
+ feature: MapGeoJSONFeature<P>;
27
+ /** Longitude of the cursor at the time of the event. */
28
+ longitude: number;
29
+ /** Latitude of the cursor at the time of the event. */
30
+ latitude: number;
31
+ /** The underlying MapLibre mouse event for advanced use cases. */
32
+ originalEvent: MapLibreGL.MapLayerMouseEvent;
33
+ };
34
+
35
+ export type MapGeoJSONProps<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> = {
36
+ /** GeoJSON data (FeatureCollection, Feature, Geometry) or a URL to fetch it from. */
37
+ data: MapGeoJSONData<P>;
38
+ /** Optional unique identifier prefix for the source/layers. Auto-generated if not provided. */
39
+ id?: string;
40
+ /**
41
+ * Feature property to promote to the feature `id`. Required for hover
42
+ * feature-state (`fillHoverPaint`) and stable `onHover`/`onClick` payloads.
43
+ */
44
+ promoteId?: string;
45
+ /**
46
+ * Paint for the polygon fill layer. Merged on top of a theme-aware neutral
47
+ * default (the `--border` token — a mid neutral that reads on the page
48
+ * surface in every theme). Pass `false` to omit the fill layer entirely
49
+ * (e.g. outlines only).
50
+ */
51
+ fillPaint?: MapFillPaint | false;
52
+ /**
53
+ * Paint for the outline layer. Merged on top of a hairline default
54
+ * (`line-color` = the `--background` token, `line-width` = 0.5) for thin
55
+ * separators. Override `line-color` if your container differs, or pass
56
+ * `false` to omit the layer.
57
+ */
58
+ linePaint?: MapLinePaint | false;
59
+ /**
60
+ * Paint merged onto the fill layer for the feature under the cursor, applied
61
+ * as a `case` expression keyed on hover feature-state. Requires `promoteId`.
62
+ */
63
+ fillHoverPaint?: MapFillPaint;
64
+ /** Callback when a feature is clicked. */
65
+ onClick?: (e: MapGeoJSONEvent<P>) => void;
66
+ /** Callback fired when the hovered feature changes; `null` when the cursor leaves. */
67
+ onHover?: (e: MapGeoJSONEvent<P> | null) => void;
68
+ /** Whether features respond to mouse events (default: false). */
69
+ interactive?: boolean;
70
+ /** Optional MapLibre layer id to insert the layers before (z-order control). */
71
+ beforeId?: string;
72
+ };
73
+
74
+ /**
75
+ * Renders arbitrary GeoJSON as fill + outline layers on the map. Composes like
76
+ * `MapRoute` / `MapArc` — drop it inside `<MapCanvas>` (typically with `blank`)
77
+ * for choropleths and region/data maps. For full control over expressions and
78
+ * multiple layers, manage layers directly via `useMap()` instead.
79
+ */
80
+ export function MapGeoJSON<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties>({
81
+ data,
82
+ id: propId,
83
+ promoteId,
84
+ fillPaint,
85
+ linePaint,
86
+ fillHoverPaint,
87
+ onClick,
88
+ onHover,
89
+ interactive = false,
90
+ beforeId,
91
+ }: MapGeoJSONProps<P>) {
92
+ const { map, isLoaded } = useMap();
93
+ const autoId = useId();
94
+ const id = propId ?? autoId;
95
+ const sourceId = `geojson-source-${id}`;
96
+ const fillLayerId = `geojson-fill-${id}`;
97
+ const lineLayerId = `geojson-line-${id}`;
98
+
99
+ // Theme-driven neutral defaults: landmass = the mid-neutral `--border` rung,
100
+ // separators = the page surface. Both re-resolve on theme change.
101
+ const defaultFill = useTokenColor("--border");
102
+ const defaultLine = useTokenColor("--background");
103
+
104
+ const showFill = fillPaint !== false;
105
+ const showLine = linePaint !== false;
106
+
107
+ const mergedFillPaint = useMemo(
108
+ () => mergeHoverPaint({ "fill-color": defaultFill, ...(fillPaint || {}) }, fillHoverPaint),
109
+ [defaultFill, fillPaint, fillHoverPaint],
110
+ );
111
+ const mergedLinePaint = useMemo(
112
+ () => ({
113
+ "line-color": defaultLine,
114
+ "line-width": 0.5,
115
+ ...(linePaint || {}),
116
+ }),
117
+ [defaultLine, linePaint],
118
+ );
119
+ const latestRef = useRef({ onClick, onHover });
120
+ latestRef.current = { onClick, onHover };
121
+
122
+ // Add source on mount.
123
+ useEffect(() => {
124
+ if (!isLoaded || !map) return;
125
+
126
+ map.addSource(sourceId, {
127
+ type: "geojson",
128
+ data,
129
+ ...(promoteId ? { promoteId } : {}),
130
+ });
131
+
132
+ return () => {
133
+ try {
134
+ if (map.getLayer(lineLayerId)) map.removeLayer(lineLayerId);
135
+ if (map.getLayer(fillLayerId)) map.removeLayer(fillLayerId);
136
+ if (map.getSource(sourceId)) map.removeSource(sourceId);
137
+ } catch {
138
+ // style may be mid-reload
139
+ }
140
+ };
141
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- source created once per map; data/layers are synced by the effects below
142
+ }, [isLoaded, map]);
143
+
144
+ // Sync data when it changes.
145
+ useEffect(() => {
146
+ if (!isLoaded || !map) return;
147
+ const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource | undefined;
148
+ source?.setData(data as never);
149
+ }, [isLoaded, map, data, sourceId]);
150
+
151
+ // Sync layers and paint when visibility or styling changes.
152
+ useEffect(() => {
153
+ if (!isLoaded || !map) return;
154
+
155
+ const source = map.getSource(sourceId);
156
+ if (!source) return;
157
+
158
+ if (showFill && !map.getLayer(fillLayerId)) {
159
+ map.addLayer(
160
+ {
161
+ id: fillLayerId,
162
+ type: "fill",
163
+ source: sourceId,
164
+ paint: mergedFillPaint,
165
+ },
166
+ beforeId,
167
+ );
168
+ } else if (!showFill && map.getLayer(fillLayerId)) {
169
+ map.removeLayer(fillLayerId);
170
+ }
171
+
172
+ if (showLine && !map.getLayer(lineLayerId)) {
173
+ map.addLayer(
174
+ {
175
+ id: lineLayerId,
176
+ type: "line",
177
+ source: sourceId,
178
+ paint: mergedLinePaint,
179
+ },
180
+ beforeId,
181
+ );
182
+ } else if (!showLine && map.getLayer(lineLayerId)) {
183
+ map.removeLayer(lineLayerId);
184
+ }
185
+
186
+ if (showFill && map.getLayer(fillLayerId)) {
187
+ for (const [key, value] of Object.entries(mergedFillPaint)) {
188
+ map.setPaintProperty(fillLayerId, key as keyof MapFillPaint, value as never);
189
+ }
190
+ }
191
+ if (showLine && map.getLayer(lineLayerId)) {
192
+ for (const [key, value] of Object.entries(mergedLinePaint)) {
193
+ map.setPaintProperty(lineLayerId, key as keyof MapLinePaint, value as never);
194
+ }
195
+ }
196
+ }, [
197
+ isLoaded,
198
+ map,
199
+ sourceId,
200
+ fillLayerId,
201
+ lineLayerId,
202
+ showFill,
203
+ showLine,
204
+ mergedFillPaint,
205
+ mergedLinePaint,
206
+ beforeId,
207
+ ]);
208
+
209
+ // Interaction handlers (bound to the fill layer).
210
+ useEffect(() => {
211
+ if (!isLoaded || !map || !interactive || !showFill) return;
212
+
213
+ let hoveredId: string | number | null = null;
214
+
215
+ const setHover = (next: string | number | null) => {
216
+ if (next === hoveredId) return;
217
+ const sourceExists = !!map.getSource(sourceId);
218
+ if (hoveredId != null && sourceExists) {
219
+ map.setFeatureState({ source: sourceId, id: hoveredId }, { hover: false });
220
+ }
221
+ hoveredId = next;
222
+ if (next != null && sourceExists) {
223
+ map.setFeatureState({ source: sourceId, id: next }, { hover: true });
224
+ }
225
+ };
226
+
227
+ const handleMouseMove = (e: MapLibreGL.MapLayerMouseEvent) => {
228
+ const feature = e.features?.[0];
229
+ if (!feature) return;
230
+ map.getCanvas().style.cursor = "pointer";
231
+
232
+ const featureId = feature.id;
233
+ if (featureId === hoveredId) return;
234
+ setHover(featureId ?? null);
235
+ latestRef.current.onHover?.({
236
+ feature: feature as unknown as MapGeoJSONFeature<P>,
237
+ longitude: e.lngLat.lng,
238
+ latitude: e.lngLat.lat,
239
+ originalEvent: e,
240
+ });
241
+ };
242
+
243
+ const handleMouseLeave = () => {
244
+ setHover(null);
245
+ map.getCanvas().style.cursor = "";
246
+ latestRef.current.onHover?.(null);
247
+ };
248
+
249
+ const handleClick = (e: MapLibreGL.MapLayerMouseEvent) => {
250
+ const feature = e.features?.[0];
251
+ if (!feature) return;
252
+ latestRef.current.onClick?.({
253
+ feature: feature as unknown as MapGeoJSONFeature<P>,
254
+ longitude: e.lngLat.lng,
255
+ latitude: e.lngLat.lat,
256
+ originalEvent: e,
257
+ });
258
+ };
259
+
260
+ map.on("mousemove", fillLayerId, handleMouseMove);
261
+ map.on("mouseleave", fillLayerId, handleMouseLeave);
262
+ map.on("click", fillLayerId, handleClick);
263
+
264
+ return () => {
265
+ map.off("mousemove", fillLayerId, handleMouseMove);
266
+ map.off("mouseleave", fillLayerId, handleMouseLeave);
267
+ map.off("click", fillLayerId, handleClick);
268
+ setHover(null);
269
+ map.getCanvas().style.cursor = "";
270
+ };
271
+ }, [isLoaded, map, fillLayerId, sourceId, interactive, showFill]);
272
+
273
+ return null;
274
+ }
@@ -0,0 +1,12 @@
1
+ export {
2
+ MapMarker,
3
+ MapMarkerContent,
4
+ MapMarkerPopup,
5
+ MapMarkerTooltip,
6
+ MapMarkerLabel,
7
+ type MapMarkerProps,
8
+ type MapMarkerContentProps,
9
+ type MapMarkerPopupProps,
10
+ type MapMarkerTooltipProps,
11
+ type MapMarkerLabelProps,
12
+ } from "./map-marker";
@@ -0,0 +1,71 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { MapPin } from "lucide-react";
3
+
4
+ import { MapCanvas } from "../map-canvas";
5
+ import {
6
+ MapMarker,
7
+ MapMarkerContent,
8
+ MapMarkerLabel,
9
+ MapMarkerPopup,
10
+ MapMarkerTooltip,
11
+ } from "./map-marker";
12
+
13
+ const meta = {
14
+ title: "Maps/MapMarker",
15
+ component: MapMarker,
16
+ tags: ["autodocs"],
17
+ parameters: { layout: "fullscreen" },
18
+ } satisfies Meta<typeof MapMarker>;
19
+ export default meta;
20
+ type Story = StoryObj<typeof meta>;
21
+
22
+ export const Default: Story = {
23
+ render: () => (
24
+ <div className="h-[480px]">
25
+ <MapCanvas center={[13.405, 52.52]} zoom={11}>
26
+ <MapMarker longitude={13.405} latitude={52.52}>
27
+ <MapMarkerContent />
28
+ <MapMarkerLabel>Berlin</MapMarkerLabel>
29
+ </MapMarker>
30
+ </MapCanvas>
31
+ </div>
32
+ ),
33
+ };
34
+
35
+ export const WithPopupAndTooltip: Story = {
36
+ render: () => (
37
+ <div className="h-[480px]">
38
+ <MapCanvas center={[13.41, 52.52]} zoom={12}>
39
+ <MapMarker longitude={13.405} latitude={52.52}>
40
+ <MapMarkerContent />
41
+ <MapMarkerTooltip>Click for details</MapMarkerTooltip>
42
+ <MapMarkerPopup closeButton>
43
+ <p className="text-body font-medium">Alexanderplatz</p>
44
+ <p className="text-caption text-muted-foreground">
45
+ Public square in the Mitte district.
46
+ </p>
47
+ </MapMarkerPopup>
48
+ </MapMarker>
49
+ <MapMarker longitude={13.377} latitude={52.516}>
50
+ <MapMarkerContent>
51
+ <MapPin className="size-6 text-primary" aria-hidden="true" />
52
+ </MapMarkerContent>
53
+ <MapMarkerLabel position="bottom">Brandenburg Gate</MapMarkerLabel>
54
+ </MapMarker>
55
+ </MapCanvas>
56
+ </div>
57
+ ),
58
+ };
59
+
60
+ export const Draggable: Story = {
61
+ render: () => (
62
+ <div className="h-[480px]">
63
+ <MapCanvas center={[13.405, 52.52]} zoom={11}>
64
+ <MapMarker longitude={13.405} latitude={52.52} draggable>
65
+ <MapMarkerContent />
66
+ <MapMarkerLabel>Drag me</MapMarkerLabel>
67
+ </MapMarker>
68
+ </MapCanvas>
69
+ </div>
70
+ ),
71
+ };
@@ -0,0 +1,81 @@
1
+ import { cleanup, render, waitFor } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ vi.mock("maplibre-gl", async () => {
5
+ const { createMaplibreMock } = await import("../test-utils/maplibre-mock");
6
+ return createMaplibreMock();
7
+ });
8
+
9
+ import { MockMarker, resetMaplibreMock } from "../test-utils/maplibre-mock";
10
+ import { MapCanvas } from "../map-canvas";
11
+ import { MapMarker, MapMarkerContent, MapMarkerLabel } from "./map-marker";
12
+
13
+ afterEach(() => {
14
+ cleanup();
15
+ resetMaplibreMock();
16
+ });
17
+
18
+ describe("MapMarker", () => {
19
+ it("adds the marker to the map at the given position", async () => {
20
+ render(
21
+ <MapCanvas>
22
+ <MapMarker longitude={13.4} latitude={52.52}>
23
+ <MapMarkerContent />
24
+ </MapMarker>
25
+ </MapCanvas>,
26
+ );
27
+ await waitFor(() => {
28
+ expect(MockMarker.instances).toHaveLength(1);
29
+ expect(MockMarker.instances[0]!.addedTo).not.toBeNull();
30
+ });
31
+ expect(MockMarker.instances[0]!.lngLat).toEqual({ lng: 13.4, lat: 52.52 });
32
+ });
33
+
34
+ it("portals content into the marker element (default icon)", async () => {
35
+ render(
36
+ <MapCanvas>
37
+ <MapMarker longitude={0} latitude={0}>
38
+ <MapMarkerContent />
39
+ </MapMarker>
40
+ </MapCanvas>,
41
+ );
42
+ await waitFor(() => {
43
+ expect(MockMarker.instances[0]!.element.querySelector(".bg-primary")).not.toBeNull();
44
+ });
45
+ });
46
+
47
+ it("renders custom content and labels", async () => {
48
+ render(
49
+ <MapCanvas>
50
+ <MapMarker longitude={0} latitude={0}>
51
+ <MapMarkerContent>
52
+ <span data-testid="pin">pin</span>
53
+ </MapMarkerContent>
54
+ <MapMarkerLabel>Berlin</MapMarkerLabel>
55
+ </MapMarker>
56
+ </MapCanvas>,
57
+ );
58
+ await waitFor(() => {
59
+ const el = MockMarker.instances[0]!.element;
60
+ expect(el.querySelector("[data-testid='pin']")).not.toBeNull();
61
+ // The label anchors to the marker element even when composed as a
62
+ // SIBLING of MapMarkerContent (it portals itself).
63
+ expect(el.textContent).toContain("Berlin");
64
+ });
65
+ });
66
+
67
+ it("removes the marker on unmount", async () => {
68
+ const { unmount } = render(
69
+ <MapCanvas>
70
+ <MapMarker longitude={0} latitude={0}>
71
+ <MapMarkerContent />
72
+ </MapMarker>
73
+ </MapCanvas>,
74
+ );
75
+ await waitFor(() => {
76
+ expect(MockMarker.instances[0]!.addedTo).not.toBeNull();
77
+ });
78
+ unmount();
79
+ expect(MockMarker.instances[0]!.addedTo).toBeNull();
80
+ });
81
+ });