@delacour/react-native-ui 0.2.0-alpha.20261015134956 → 0.2.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.
@@ -0,0 +1,467 @@
1
+ import type { StyleSpecification, SymbolLayerSpecification } from "@maplibre/maplibre-react-native";
2
+ import type { VariantProps } from "tailwind-variants";
3
+ import { tv } from "../../lib/tv";
4
+ import type { LngLat, LngLatBounds } from "./map.types";
5
+ import { fitBoundsCamera } from "./map-geometry";
6
+
7
+ /** Which point of a marker sits on its coordinate. */
8
+ export const MAP_MARKER_ANCHORS = ["center", "top", "bottom", "left", "right"] as const;
9
+
10
+ /** Where `Map.Controls` sits. */
11
+ export const MAP_CONTROLS_CORNERS = ["top-left", "top-right", "bottom-left", "bottom-right"] as const;
12
+
13
+ /** A pin's colour — the theme's primary, its foreground, or destructive. */
14
+ export const MAP_PIN_TONES = ["primary", "default", "destructive"] as const;
15
+
16
+ export const MAP_PIN_SIZES = ["sm", "md"] as const;
17
+
18
+ /** Which side of the pin a label keeps its distance from. */
19
+ export const MAP_LABEL_SIDES = ["top", "bottom"] as const;
20
+
21
+ export const MAP_LABEL_SIZES = ["sm", "md"] as const;
22
+
23
+ export const MAP_LABEL_TONES = ["default", "muted", "primary"] as const;
24
+
25
+ export type MapMarkerAnchor = (typeof MAP_MARKER_ANCHORS)[number];
26
+ export type MapControlsCorner = (typeof MAP_CONTROLS_CORNERS)[number];
27
+ export type MapPinTone = (typeof MAP_PIN_TONES)[number];
28
+ export type MapPinSize = (typeof MAP_PIN_SIZES)[number];
29
+ export type MapLabelSide = (typeof MAP_LABEL_SIDES)[number];
30
+ export type MapLabelSize = (typeof MAP_LABEL_SIZES)[number];
31
+ export type MapLabelTone = (typeof MAP_LABEL_TONES)[number];
32
+
33
+ /** A point in a view's unit square — `{ x: 0.5, y: 1 }` is bottom centre. */
34
+ export type MapAnchorPoint = { x: number; y: number };
35
+
36
+ /** What the map view is announced as. */
37
+ export const MAP_ACCESSIBILITY_LABEL = "Map";
38
+
39
+ /** How long a camera move takes when the caller names no duration, in ms. */
40
+ export const MAP_CAMERA_DURATION = 600;
41
+
42
+ /** How far one press of a zoom control moves — one step of the tile pyramid. */
43
+ export const MAP_ZOOM_STEP = 1;
44
+
45
+ /** What a map shows when it is given neither a centre nor a box. */
46
+ export const MAP_DEFAULT_ZOOM = 2;
47
+
48
+ /** The colour token a route, an arc and a pin take unless told otherwise. */
49
+ export const MAP_ROUTE_TOKEN = "primary";
50
+
51
+ /**
52
+ * A pin's fill and the dot punched in its head. The dot is the fill's own
53
+ * foreground, so it holds its contrast in every theme without a second choice.
54
+ */
55
+ export const MAP_PIN_TONE_TOKENS: Record<MapPinTone, { fill: string; dot: string }> = {
56
+ primary: { fill: "primary", dot: "primary-foreground" },
57
+ default: { fill: "foreground", dot: "background" },
58
+ destructive: { fill: "destructive", dot: "destructive-foreground" },
59
+ };
60
+
61
+ /** A pin's box in points. Taller than wide: a round head and a tip. */
62
+ export const MAP_PIN_DIMENSIONS: Record<MapPinSize, { width: number; height: number }> = {
63
+ sm: { width: 20, height: 26 },
64
+ md: { width: 28, height: 36 },
65
+ };
66
+
67
+ /**
68
+ * A pin's outline in its own box: a round head the full width, narrowing to a
69
+ * tip at bottom centre. The path starts at the tip, because the tip is the
70
+ * point the marker anchors on.
71
+ */
72
+ export function mapPinPath(width: number, height: number): string {
73
+ const r = width / 2;
74
+ const cx = r;
75
+ const cy = r;
76
+ // Where the head's circle hands over to the straight sides running to the
77
+ // tip, about 40° below the horizontal on each side.
78
+ const shoulderY = cy + r * 0.64;
79
+ const shoulderDx = r * 0.77;
80
+
81
+ return [
82
+ `M${cx} ${height}`,
83
+ `L${cx - shoulderDx} ${shoulderY}`,
84
+ `A${r} ${r} 0 1 1 ${cx + shoulderDx} ${shoulderY}`,
85
+ "Z",
86
+ ].join(" ");
87
+ }
88
+
89
+ /** The unit-square point a named anchor stands for. */
90
+ export function resolveMarkerAnchor(anchor: MapMarkerAnchor): MapAnchorPoint {
91
+ switch (anchor) {
92
+ case "center":
93
+ return { x: 0.5, y: 0.5 };
94
+ case "top":
95
+ return { x: 0.5, y: 0 };
96
+ case "bottom":
97
+ return { x: 0.5, y: 1 };
98
+ case "left":
99
+ return { x: 0, y: 0.5 };
100
+ case "right":
101
+ return { x: 1, y: 0.5 };
102
+ }
103
+ }
104
+
105
+ type Size = { width: number; height: number };
106
+ type Frame = Size & { x: number; y: number };
107
+
108
+ /**
109
+ * The anchor that lands a pin's tip on the coordinate, wherever the pin sits
110
+ * inside the marker — under a popup, above a label, or alone.
111
+ *
112
+ * This is what lets a marker hold a pin and a label, or a pin with a popup over
113
+ * it, without the caller computing an offset: the marker anchors on the tip of
114
+ * the pin it measured rather than on its own bottom edge. `undefined` until both
115
+ * have been measured.
116
+ */
117
+ export function resolvePinTipAnchor({ marker, pin }: { marker: Size; pin: Frame }): MapAnchorPoint | undefined {
118
+ if (marker.width <= 0 || marker.height <= 0 || pin.width <= 0 || pin.height <= 0) return undefined;
119
+
120
+ return { x: (pin.x + pin.width / 2) / marker.width, y: (pin.y + pin.height) / marker.height };
121
+ }
122
+
123
+ /**
124
+ * The anchor as a shift in points, for a marker placed by its centre.
125
+ *
126
+ * MapLibre takes a named anchor and a pixel `offset`, not a point in the unit
127
+ * square, so a pin's tip — which can sit anywhere in its marker — is reached by
128
+ * centring the marker and moving it. Positive moves right and down, so a
129
+ * bottom anchor is a lift of half the view's height.
130
+ */
131
+ export function resolveMarkerOffset(anchor: MapAnchorPoint, size: Size): [x: number, y: number] {
132
+ return [(0.5 - anchor.x) * size.width, (0.5 - anchor.y) * size.height];
133
+ }
134
+
135
+ const HEX = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
136
+
137
+ /**
138
+ * A theme colour as the style spec reads it.
139
+ *
140
+ * Uniwind hands theme colours to JavaScript as hex. MapLibre parses three- and
141
+ * six-digit hex but not eight, so a token carrying alpha is rewritten as
142
+ * `rgba()` rather than drawn opaque or not at all. Any other notation passes
143
+ * through untouched.
144
+ */
145
+ export function toStyleColor(color: string | undefined): string | undefined {
146
+ if (color === undefined || !HEX.test(color) || color.length !== 9) return color;
147
+ const channel = (index: number) => Number.parseInt(color.slice(1 + index * 2, 3 + index * 2), 16);
148
+ const alpha = Math.round((channel(3) / 255) * 1000) / 1000;
149
+ return `rgba(${channel(0)}, ${channel(1)}, ${channel(2)}, ${alpha})`;
150
+ }
151
+
152
+ /** The theme tokens the basemap is painted from. */
153
+ export const MAP_STYLE_TOKENS = {
154
+ background: "background",
155
+ border: "border",
156
+ muted: "muted",
157
+ mutedForeground: "muted-foreground",
158
+ } as const;
159
+
160
+ export type MapStyleTokens = Record<keyof typeof MAP_STYLE_TOKENS, string | undefined>;
161
+
162
+ /**
163
+ * Where the basemap's data comes from: a TileJSON URL for vector tiles in the
164
+ * OpenMapTiles schema, the glyph URL template its labels are set from, and the
165
+ * font stack to ask that server for.
166
+ */
167
+ export type MapBasemap = {
168
+ tiles: string;
169
+ glyphs: string;
170
+ font: string;
171
+ };
172
+
173
+ /**
174
+ * OpenFreeMap: OpenMapTiles-schema vector tiles of OpenStreetMap, free and with
175
+ * no key. The data is © OpenStreetMap contributors under the ODbL, which is why
176
+ * the map keeps its attribution button.
177
+ */
178
+ export const MAP_DEFAULT_BASEMAP: MapBasemap = {
179
+ tiles: "https://tiles.openfreemap.org/planet",
180
+ glyphs: "https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf",
181
+ font: "Noto Sans Regular",
182
+ };
183
+
184
+ type StyleLayer = StyleSpecification["layers"][number];
185
+
186
+ /** A place's name, in English where the data has it. */
187
+ const NAME: NonNullable<SymbolLayerSpecification["layout"]>["text-field"] = [
188
+ "coalesce",
189
+ ["get", "name:en"],
190
+ ["get", "name"],
191
+ ];
192
+
193
+ /**
194
+ * A MapLibre style painted from the theme: land on the page's `background`,
195
+ * water and green space in `muted`, roads and buildings in `border`, and labels
196
+ * in `muted-foreground` haloed in `background`. Retune the theme and the map
197
+ * retunes with it, on both platforms.
198
+ *
199
+ * A token that did not resolve drops the layers it paints rather than leaving
200
+ * MapLibre to draw them in its default black.
201
+ */
202
+ export function buildMapStyle(tokens: MapStyleTokens, basemap: MapBasemap = MAP_DEFAULT_BASEMAP): StyleSpecification {
203
+ const background = toStyleColor(tokens.background);
204
+ const border = toStyleColor(tokens.border);
205
+ const muted = toStyleColor(tokens.muted);
206
+ const label = toStyleColor(tokens.mutedForeground);
207
+ const source = "openmaptiles";
208
+ const font = [basemap.font];
209
+ const when = (color: string | undefined, layers: (color: string) => StyleLayer[]): StyleLayer[] =>
210
+ color === undefined ? [] : layers(color);
211
+
212
+ const halo = background === undefined ? {} : { "text-halo-color": background, "text-halo-width": 1.2 };
213
+
214
+ return {
215
+ version: 8,
216
+ name: "Delacour",
217
+ glyphs: basemap.glyphs,
218
+ sources: { [source]: { type: "vector", url: basemap.tiles } },
219
+ layers: [
220
+ ...when(background, (color) => [{ id: "background", type: "background", paint: { "background-color": color } }]),
221
+ ...when(muted, (color) => [
222
+ {
223
+ id: "landcover",
224
+ type: "fill",
225
+ source,
226
+ "source-layer": "landcover",
227
+ filter: ["in", ["get", "class"], ["literal", ["wood", "grass", "farmland", "wetland"]]],
228
+ paint: { "fill-color": color, "fill-opacity": 0.5 },
229
+ },
230
+ {
231
+ id: "park",
232
+ type: "fill",
233
+ source,
234
+ "source-layer": "park",
235
+ paint: { "fill-color": color, "fill-opacity": 0.6 },
236
+ },
237
+ { id: "water", type: "fill", source, "source-layer": "water", paint: { "fill-color": color } },
238
+ {
239
+ id: "waterway",
240
+ type: "line",
241
+ source,
242
+ "source-layer": "waterway",
243
+ paint: { "line-color": color, "line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.5, 16, 3] },
244
+ },
245
+ ]),
246
+ ...when(border, (color) => [
247
+ {
248
+ id: "building",
249
+ type: "fill",
250
+ source,
251
+ "source-layer": "building",
252
+ minzoom: 14,
253
+ paint: { "fill-color": color, "fill-opacity": 0.6 },
254
+ },
255
+ {
256
+ id: "boundary",
257
+ type: "line",
258
+ source,
259
+ "source-layer": "boundary",
260
+ filter: ["all", ["<=", ["get", "admin_level"], 4], ["!=", ["get", "maritime"], 1]],
261
+ paint: {
262
+ "line-color": color,
263
+ "line-dasharray": ["case", ["<=", ["get", "admin_level"], 2], ["literal", [1, 0]], ["literal", [3, 2]]],
264
+ "line-width": ["interpolate", ["linear"], ["zoom"], 2, 0.5, 10, 1.5],
265
+ },
266
+ },
267
+ {
268
+ id: "road-minor",
269
+ type: "line",
270
+ source,
271
+ "source-layer": "transportation",
272
+ minzoom: 12,
273
+ filter: ["in", ["get", "class"], ["literal", ["minor", "service", "track", "path"]]],
274
+ layout: { "line-cap": "round", "line-join": "round" },
275
+ paint: {
276
+ "line-color": color,
277
+ "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 12, 0.5, 18, 8],
278
+ },
279
+ },
280
+ {
281
+ id: "road-major",
282
+ type: "line",
283
+ source,
284
+ "source-layer": "transportation",
285
+ minzoom: 5,
286
+ filter: ["in", ["get", "class"], ["literal", ["motorway", "trunk", "primary", "secondary", "tertiary"]]],
287
+ layout: { "line-cap": "round", "line-join": "round" },
288
+ paint: {
289
+ "line-color": color,
290
+ "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 5, 0.5, 18, 14],
291
+ },
292
+ },
293
+ ]),
294
+ ...when(label, (color) => [
295
+ {
296
+ id: "water-name",
297
+ type: "symbol",
298
+ source,
299
+ "source-layer": "water_name",
300
+ layout: { "text-field": NAME, "text-font": font, "text-size": 11 },
301
+ paint: { "text-color": color, ...halo },
302
+ },
303
+ {
304
+ id: "road-name",
305
+ type: "symbol",
306
+ source,
307
+ "source-layer": "transportation_name",
308
+ minzoom: 14,
309
+ layout: {
310
+ "symbol-placement": "line",
311
+ "text-field": NAME,
312
+ "text-font": font,
313
+ "text-size": 11,
314
+ },
315
+ paint: { "text-color": color, ...halo },
316
+ },
317
+ {
318
+ id: "place",
319
+ type: "symbol",
320
+ source,
321
+ "source-layer": "place",
322
+ filter: ["!=", ["get", "class"], "continent"],
323
+ layout: {
324
+ "text-field": NAME,
325
+ "text-font": font,
326
+ "text-max-width": 8,
327
+ "text-size": ["match", ["get", "class"], "country", 13, "city", 14, "town", 12, 11],
328
+ },
329
+ paint: { "text-color": color, ...halo },
330
+ },
331
+ ]),
332
+ ],
333
+ };
334
+ }
335
+
336
+ /**
337
+ * A dashed route's on and off lengths. MapLibre measures a dash in multiples of
338
+ * the line's width, so one pattern stays dashes at any weight. `undefined` is solid.
339
+ */
340
+ export function resolveRouteDashArray(isDashed: boolean): [on: number, off: number] | undefined {
341
+ return isDashed ? [2, 2] : undefined;
342
+ }
343
+
344
+ /**
345
+ * Which gestures the map takes. Rotation and tilt travel together — a map that
346
+ * can be turned but not tilted back flat is one a reader gets lost in — and a
347
+ * map that is not interactive takes none, rotation included.
348
+ */
349
+ export function resolveMapGestures({ isInteractive, isRotatable }: { isInteractive: boolean; isRotatable: boolean }): {
350
+ dragPan: boolean;
351
+ touchZoom: boolean;
352
+ doubleTapZoom: boolean;
353
+ doubleTapHoldZoom: boolean;
354
+ touchRotate: boolean;
355
+ touchPitch: boolean;
356
+ } {
357
+ const rotates = isInteractive && isRotatable;
358
+ return {
359
+ doubleTapHoldZoom: isInteractive,
360
+ doubleTapZoom: isInteractive,
361
+ dragPan: isInteractive,
362
+ touchPitch: rotates,
363
+ touchRotate: rotates,
364
+ touchZoom: isInteractive,
365
+ };
366
+ }
367
+
368
+ /** A camera move's duration: the caller's, else the default, and a jump when motion is reduced. */
369
+ export function resolveCameraDuration(duration: number | undefined, isReducedMotion: boolean): number {
370
+ if (isReducedMotion) return 0;
371
+ return Math.max(0, duration ?? MAP_CAMERA_DURATION);
372
+ }
373
+
374
+ /** A device heading as a rotation in degrees, or `undefined` when the device does not know it. */
375
+ export function resolveHeadingRotation(heading: number | undefined): number | undefined {
376
+ if (heading === undefined || !Number.isFinite(heading) || heading < 0) return undefined;
377
+ return heading;
378
+ }
379
+
380
+ /** The view a map mounts with. `bounds` wins; then a centre and zoom; then the world at zoom 2. */
381
+ export type MapInitialView = { center?: LngLat; zoom?: number; bounds?: LngLatBounds };
382
+
383
+ /**
384
+ * The centre and zoom a map mounts on, for a view of the given size. A box is
385
+ * framed edge to edge — the caller's box, not the caller's box plus a margin.
386
+ */
387
+ export function resolveInitialCamera({
388
+ size,
389
+ bounds,
390
+ center,
391
+ zoom,
392
+ }: MapInitialView & { size: { width: number; height: number } }): { center: LngLat; zoom: number } {
393
+ if (bounds !== undefined) return fitBoundsCamera(bounds, size);
394
+ return { center: center ?? [0, 0], zoom: zoom ?? MAP_DEFAULT_ZOOM };
395
+ }
396
+
397
+ function sameNumbers(a: readonly number[] | undefined, b: readonly number[] | undefined): boolean {
398
+ if (a === undefined || b === undefined) return a === b;
399
+ return a.length === b.length && a.every((value, index) => value === b[index]);
400
+ }
401
+
402
+ /**
403
+ * Whether the initial-view props now name a different view.
404
+ *
405
+ * Compared by value, because `center={[174.7, -41.2]}` is a new array every
406
+ * render; by reference every map would warn on every render. The map uses it
407
+ * to warn, in development, that a changed prop will not move the camera.
408
+ */
409
+ export function hasInitialViewChanged(previous: MapInitialView, next: MapInitialView): boolean {
410
+ return (
411
+ !sameNumbers(previous.center, next.center) ||
412
+ previous.zoom !== next.zoom ||
413
+ !sameNumbers(previous.bounds, next.bounds)
414
+ );
415
+ }
416
+
417
+ /**
418
+ * Every styled part of the map, in one slotted `tv()`.
419
+ *
420
+ * The map view itself takes no class: a native view that is not a React
421
+ * Native primitive cannot, so the root is a `View` the map fills, and the
422
+ * caller sizes the root.
423
+ */
424
+ export const mapVariants = tv({
425
+ slots: {
426
+ root: "h-72 w-full overflow-hidden rounded-lg bg-muted",
427
+ /** The layer floating parts sit on. `box-none` passes touches on empty space to the map. */
428
+ overlay: "absolute inset-0",
429
+ marker: "items-center",
430
+ label: "max-w-40 rounded-full px-2 py-0.5",
431
+ labelText: "font-medium",
432
+ popup: "max-w-64 gap-1",
433
+ popupTitle: "font-semibold text-card-foreground",
434
+ popupCaret: "-mt-px self-center",
435
+ controls: "absolute gap-2",
436
+ userHeading: "items-center justify-center",
437
+ },
438
+ variants: {
439
+ corner: {
440
+ "top-left": { controls: "top-3 left-3" },
441
+ "top-right": { controls: "top-3 right-3" },
442
+ "bottom-left": { controls: "bottom-3 left-3" },
443
+ "bottom-right": { controls: "right-3 bottom-3" },
444
+ },
445
+ labelSide: {
446
+ top: { label: "mb-1" },
447
+ bottom: { label: "mt-1" },
448
+ },
449
+ labelSize: {
450
+ sm: { labelText: "text-xs" },
451
+ md: { labelText: "text-sm" },
452
+ },
453
+ labelTone: {
454
+ default: { labelText: "text-card-foreground" },
455
+ muted: { labelText: "text-muted-foreground" },
456
+ primary: { labelText: "text-primary" },
457
+ },
458
+ },
459
+ defaultVariants: {
460
+ corner: "bottom-right",
461
+ labelSide: "bottom",
462
+ labelSize: "sm",
463
+ labelTone: "default",
464
+ },
465
+ });
466
+
467
+ export type MapVariantProps = VariantProps<typeof mapVariants>;
@@ -0,0 +1,74 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { join, relative, sep } from "node:path";
4
+
5
+ const SRC = import.meta.dirname;
6
+
7
+ /** The one folder allowed to import the native map module. */
8
+ const MAP_FOLDER = join(SRC, "components", "map") + sep;
9
+
10
+ /** An import or re-export of MapLibre, static or dynamic, the package or any path inside it. */
11
+ const MAPS_IMPORT = /(?:from\s+|import\s*\(\s*|require\s*\(\s*)["']@maplibre\/[^"']+["']/;
12
+
13
+ /** An import that reaches into the map folder from outside it. */
14
+ const MAP_FOLDER_IMPORT = /from\s+["'](?:\.\.\/)+(?:components\/)?map(?:\/[^"']*)?["']/;
15
+
16
+ /**
17
+ * `@maplibre/maplibre-react-native` stays behind `@delacour/react-native-ui/map`.
18
+ *
19
+ * It is an **optional** peer: an app that never imports the map never installs
20
+ * it, and the granular exports are what make that safe — but only for as long
21
+ * as no other component's import graph reaches it. One stray import from a
22
+ * shared helper and every app using that helper fails to bundle with "Unable
23
+ * to resolve module @maplibre/maplibre-react-native", for a map it never drew. The same
24
+ * holds for an import of the map folder itself, which would pull the module in
25
+ * transitively.
26
+ *
27
+ * Read as text, importing nothing, like `docs.test.ts` — `bun test` cannot
28
+ * parse React Native's Flow-typed source, and it does not need to.
29
+ */
30
+ function sourceFiles(dir: string): string[] {
31
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
32
+ const path = join(dir, entry.name);
33
+ if (entry.isDirectory()) return sourceFiles(path);
34
+ return /\.tsx?$/.test(entry.name) && !entry.name.endsWith(".test.ts") ? [path] : [];
35
+ });
36
+ }
37
+
38
+ const FILES = sourceFiles(SRC);
39
+ const OUTSIDE = FILES.filter((path) => !path.startsWith(MAP_FOLDER));
40
+
41
+ describe("MapLibre stays inside the map folder", () => {
42
+ // An empty walk would let the assertions below pass on nothing.
43
+ test("finds the source tree, and the map folder in it", () => {
44
+ expect(OUTSIDE.length).toBeGreaterThan(50);
45
+ expect(FILES.some((path) => path.startsWith(MAP_FOLDER) && MAPS_IMPORT.test(readFileSync(path, "utf-8")))).toBe(
46
+ true
47
+ );
48
+ });
49
+
50
+ test("no file outside it imports the native module", () => {
51
+ const offenders = OUTSIDE.filter((path) => MAPS_IMPORT.test(readFileSync(path, "utf-8"))).map((path) =>
52
+ relative(SRC, path)
53
+ );
54
+ expect(offenders).toEqual([]);
55
+ });
56
+
57
+ test("no file outside it imports the map folder", () => {
58
+ const offenders = OUTSIDE.filter((path) => MAP_FOLDER_IMPORT.test(readFileSync(path, "utf-8"))).map((path) =>
59
+ relative(SRC, path)
60
+ );
61
+ expect(offenders).toEqual([]);
62
+ });
63
+
64
+ test("it is an optional peer of the package", () => {
65
+ const pkg = JSON.parse(readFileSync(join(SRC, "..", "package.json"), "utf-8")) as {
66
+ dependencies?: Record<string, string>;
67
+ peerDependencies?: Record<string, string>;
68
+ peerDependenciesMeta?: Record<string, { optional?: boolean }>;
69
+ };
70
+ expect(pkg.dependencies?.["@maplibre/maplibre-react-native"]).toBeUndefined();
71
+ expect(pkg.peerDependencies?.["@maplibre/maplibre-react-native"]).toBeDefined();
72
+ expect(pkg.peerDependenciesMeta?.["@maplibre/maplibre-react-native"]?.optional).toBe(true);
73
+ });
74
+ });