@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,353 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { declaredTokens } from "../../styles/theme-tokens.test";
3
+ import {
4
+ buildMapStyle,
5
+ hasInitialViewChanged,
6
+ MAP_ACCESSIBILITY_LABEL,
7
+ MAP_CAMERA_DURATION,
8
+ MAP_CONTROLS_CORNERS,
9
+ MAP_DEFAULT_BASEMAP,
10
+ MAP_DEFAULT_ZOOM,
11
+ MAP_LABEL_SIDES,
12
+ MAP_LABEL_SIZES,
13
+ MAP_LABEL_TONES,
14
+ MAP_MARKER_ANCHORS,
15
+ MAP_PIN_DIMENSIONS,
16
+ MAP_PIN_SIZES,
17
+ MAP_PIN_TONE_TOKENS,
18
+ MAP_PIN_TONES,
19
+ MAP_ROUTE_TOKEN,
20
+ MAP_STYLE_TOKENS,
21
+ mapPinPath,
22
+ mapVariants,
23
+ resolveCameraDuration,
24
+ resolveHeadingRotation,
25
+ resolveInitialCamera,
26
+ resolveMapGestures,
27
+ resolveMarkerAnchor,
28
+ resolveMarkerOffset,
29
+ resolvePinTipAnchor,
30
+ resolveRouteDashArray,
31
+ toStyleColor,
32
+ } from "./map.variants";
33
+ import { fitBoundsCamera } from "./map-geometry";
34
+
35
+ const TEXT_COLOR = /(^|\s)text-(?!(xs|sm|base|lg|xl|\dxl|center|left|right)\b)[a-z-]+/;
36
+
37
+ describe("resolveMarkerAnchor", () => {
38
+ test("names the point of the marker that sits on the coordinate", () => {
39
+ expect(resolveMarkerAnchor("center")).toEqual({ x: 0.5, y: 0.5 });
40
+ expect(resolveMarkerAnchor("top")).toEqual({ x: 0.5, y: 0 });
41
+ expect(resolveMarkerAnchor("bottom")).toEqual({ x: 0.5, y: 1 });
42
+ expect(resolveMarkerAnchor("left")).toEqual({ x: 0, y: 0.5 });
43
+ expect(resolveMarkerAnchor("right")).toEqual({ x: 1, y: 0.5 });
44
+ });
45
+
46
+ test("covers every anchor", () => {
47
+ for (const anchor of MAP_MARKER_ANCHORS) {
48
+ const { x, y } = resolveMarkerAnchor(anchor);
49
+ expect(x).toBeGreaterThanOrEqual(0);
50
+ expect(y).toBeLessThanOrEqual(1);
51
+ }
52
+ });
53
+ });
54
+
55
+ describe("resolvePinTipAnchor", () => {
56
+ test("puts the pin's tip on the coordinate, wherever the pin sits in the marker", () => {
57
+ const anchor = resolvePinTipAnchor({
58
+ marker: { width: 120, height: 100 },
59
+ pin: { x: 46, y: 64, width: 28, height: 36 },
60
+ });
61
+ expect(anchor).toEqual({ x: 0.5, y: 1 });
62
+ });
63
+
64
+ test("a pin under a popup anchors at the bottom of the whole marker", () => {
65
+ const anchor = resolvePinTipAnchor({
66
+ marker: { width: 200, height: 136 },
67
+ pin: { x: 86, y: 100, width: 28, height: 36 },
68
+ });
69
+ expect(anchor).toEqual({ x: 0.5, y: 1 });
70
+ });
71
+
72
+ test("a pin above a label anchors part-way down", () => {
73
+ const anchor = resolvePinTipAnchor({
74
+ marker: { width: 80, height: 60 },
75
+ pin: { x: 26, y: 0, width: 28, height: 36 },
76
+ });
77
+ expect(anchor).toEqual({ x: 0.5, y: 0.6 });
78
+ });
79
+
80
+ test("nothing measured is no anchor", () => {
81
+ expect(resolvePinTipAnchor({ marker: { width: 0, height: 0 }, pin: { x: 0, y: 0, width: 0, height: 0 } })).toBe(
82
+ undefined
83
+ );
84
+ });
85
+ });
86
+
87
+ describe("resolveMarkerOffset", () => {
88
+ test("a centred anchor needs no offset", () => {
89
+ expect(resolveMarkerOffset({ x: 0.5, y: 0.5 }, { width: 40, height: 60 })).toEqual([0, 0]);
90
+ });
91
+
92
+ test("a bottom anchor lifts the view by half its height", () => {
93
+ expect(resolveMarkerOffset({ x: 0.5, y: 1 }, { width: 40, height: 60 })).toEqual([0, -30]);
94
+ });
95
+
96
+ test("a left anchor pushes the view right by half its width", () => {
97
+ expect(resolveMarkerOffset({ x: 0, y: 0.5 }, { width: 40, height: 60 })).toEqual([20, 0]);
98
+ });
99
+ });
100
+
101
+ describe("pin", () => {
102
+ test("every tone names a fill and a dot the theme declares, in both variants", () => {
103
+ for (const tone of MAP_PIN_TONES) {
104
+ const { fill, dot } = MAP_PIN_TONE_TOKENS[tone];
105
+ for (const variant of ["light", "dark"] as const) {
106
+ expect(declaredTokens(variant).has(fill)).toBe(true);
107
+ expect(declaredTokens(variant).has(dot)).toBe(true);
108
+ }
109
+ }
110
+ });
111
+
112
+ test("the dot is the fill's own foreground, so it reads in every theme", () => {
113
+ expect(MAP_PIN_TONE_TOKENS.primary).toEqual({ fill: "primary", dot: "primary-foreground" });
114
+ expect(MAP_PIN_TONE_TOKENS.destructive).toEqual({ fill: "destructive", dot: "destructive-foreground" });
115
+ });
116
+
117
+ test("md is bigger than sm, and both are taller than wide", () => {
118
+ const { sm, md } = MAP_PIN_DIMENSIONS;
119
+ expect(md.width).toBeGreaterThan(sm.width);
120
+ for (const size of MAP_PIN_SIZES) {
121
+ expect(MAP_PIN_DIMENSIONS[size].height).toBeGreaterThan(MAP_PIN_DIMENSIONS[size].width);
122
+ }
123
+ });
124
+
125
+ test("the outline starts at the tip, bottom centre, and closes", () => {
126
+ const path = mapPinPath(28, 36);
127
+ expect(path.startsWith("M14 36")).toBe(true);
128
+ expect(path.endsWith("Z")).toBe(true);
129
+ });
130
+ });
131
+
132
+ describe("toStyleColor", () => {
133
+ test("hex MapLibre reads passes through", () => {
134
+ expect(toStyleColor("#3b82f6")).toBe("#3b82f6");
135
+ expect(toStyleColor("#abc")).toBe("#abc");
136
+ });
137
+
138
+ test("eight-digit hex, which it does not, becomes rgba", () => {
139
+ expect(toStyleColor("#3b82f680")).toBe("rgba(59, 130, 246, 0.502)");
140
+ expect(toStyleColor("#000000ff")).toBe("rgba(0, 0, 0, 1)");
141
+ });
142
+
143
+ test("anything else is left alone", () => {
144
+ expect(toStyleColor("rgb(1, 2, 3)")).toBe("rgb(1, 2, 3)");
145
+ expect(toStyleColor(undefined)).toBeUndefined();
146
+ });
147
+ });
148
+
149
+ describe("buildMapStyle", () => {
150
+ const TOKENS = { background: "#fafafa", border: "#e4e4e7", muted: "#f4f4f5", mutedForeground: "#71717a" };
151
+ type Paint = Record<string, unknown>;
152
+ const layer = (style: ReturnType<typeof buildMapStyle>, id: string) =>
153
+ style.layers.find((entry) => entry.id === id) as { paint?: Paint; layout?: Paint } | undefined;
154
+
155
+ test("land is the page, water muted, roads the border, labels the muted foreground haloed in the page", () => {
156
+ const style = buildMapStyle(TOKENS);
157
+ expect(layer(style, "background")?.paint?.["background-color"]).toBe("#fafafa");
158
+ expect(layer(style, "water")?.paint?.["fill-color"]).toBe("#f4f4f5");
159
+ expect(layer(style, "road-major")?.paint?.["line-color"]).toBe("#e4e4e7");
160
+ expect(layer(style, "place")?.paint?.["text-color"]).toBe("#71717a");
161
+ expect(layer(style, "place")?.paint?.["text-halo-color"]).toBe("#fafafa");
162
+ });
163
+
164
+ test("reads its tiles and glyphs from the basemap, OpenFreeMap by default", () => {
165
+ const style = buildMapStyle(TOKENS);
166
+ expect(style.glyphs).toBe(MAP_DEFAULT_BASEMAP.glyphs);
167
+ expect(style.sources.openmaptiles).toEqual({ type: "vector", url: MAP_DEFAULT_BASEMAP.tiles });
168
+ expect(layer(style, "place")?.layout?.["text-font"]).toEqual([MAP_DEFAULT_BASEMAP.font]);
169
+
170
+ const own = buildMapStyle(TOKENS, {
171
+ font: "Inter Regular",
172
+ glyphs: "https://x/{fontstack}/{range}",
173
+ tiles: "https://x",
174
+ });
175
+ expect(own.sources.openmaptiles).toEqual({ type: "vector", url: "https://x" });
176
+ expect(layer(own, "place")?.layout?.["text-font"]).toEqual(["Inter Regular"]);
177
+ });
178
+
179
+ test("an alpha token is written as rgba, which the style parser reads", () => {
180
+ const style = buildMapStyle({ ...TOKENS, muted: "#f4f4f580" });
181
+ expect(layer(style, "water")?.paint?.["fill-color"]).toBe("rgba(244, 244, 245, 0.502)");
182
+ });
183
+
184
+ test("a token that did not resolve drops its layers rather than painting them black", () => {
185
+ const style = buildMapStyle({ ...TOKENS, muted: undefined });
186
+ expect(layer(style, "water")).toBeUndefined();
187
+ expect(layer(style, "background")).toBeDefined();
188
+ });
189
+
190
+ test("labels drop their halo, not themselves, when the page colour is missing", () => {
191
+ const style = buildMapStyle({ ...TOKENS, background: undefined });
192
+ expect(layer(style, "place")?.paint?.["text-halo-color"]).toBeUndefined();
193
+ expect(layer(style, "place")?.paint?.["text-color"]).toBe("#71717a");
194
+ });
195
+
196
+ test("every layer id is unique", () => {
197
+ const ids = buildMapStyle(TOKENS).layers.map((entry) => entry.id);
198
+ expect(new Set(ids).size).toBe(ids.length);
199
+ });
200
+
201
+ test("reads tokens every theme declares", () => {
202
+ for (const token of Object.values(MAP_STYLE_TOKENS)) {
203
+ expect(declaredTokens("light").has(token)).toBe(true);
204
+ expect(declaredTokens("dark").has(token)).toBe(true);
205
+ }
206
+ });
207
+ });
208
+
209
+ describe("resolveRouteDashArray", () => {
210
+ test("solid by default", () => {
211
+ expect(resolveRouteDashArray(false)).toBeUndefined();
212
+ });
213
+
214
+ test("dashes are in line widths, so one pattern holds at any weight", () => {
215
+ expect(resolveRouteDashArray(true)).toEqual([2, 2]);
216
+ });
217
+
218
+ test("the default line colour is a token the theme declares", () => {
219
+ expect(declaredTokens("light").has(MAP_ROUTE_TOKEN)).toBe(true);
220
+ });
221
+ });
222
+
223
+ describe("resolveMapGestures", () => {
224
+ test("interactive and flat by default", () => {
225
+ expect(resolveMapGestures({ isInteractive: true, isRotatable: false })).toEqual({
226
+ doubleTapHoldZoom: true,
227
+ doubleTapZoom: true,
228
+ dragPan: true,
229
+ touchPitch: false,
230
+ touchRotate: false,
231
+ touchZoom: true,
232
+ });
233
+ });
234
+
235
+ test("rotatable turns on rotation and tilt together", () => {
236
+ const gestures = resolveMapGestures({ isInteractive: true, isRotatable: true });
237
+ expect(gestures.touchRotate).toBe(true);
238
+ expect(gestures.touchPitch).toBe(true);
239
+ });
240
+
241
+ test("not interactive turns everything off, rotation included", () => {
242
+ expect(Object.values(resolveMapGestures({ isInteractive: false, isRotatable: true }))).toEqual(
243
+ Array(6).fill(false)
244
+ );
245
+ });
246
+ });
247
+
248
+ describe("resolveCameraDuration", () => {
249
+ test("defaults, honours a caller's, and jumps when motion is reduced", () => {
250
+ expect(resolveCameraDuration(undefined, false)).toBe(MAP_CAMERA_DURATION);
251
+ expect(resolveCameraDuration(250, false)).toBe(250);
252
+ expect(resolveCameraDuration(250, true)).toBe(0);
253
+ expect(resolveCameraDuration(-5, false)).toBe(0);
254
+ });
255
+ });
256
+
257
+ describe("resolveHeadingRotation", () => {
258
+ test("a heading is a rotation", () => {
259
+ expect(resolveHeadingRotation(90)).toBe(90);
260
+ });
261
+
262
+ test("an unknown heading draws nothing", () => {
263
+ expect(resolveHeadingRotation(-1)).toBeUndefined();
264
+ expect(resolveHeadingRotation(Number.NaN)).toBeUndefined();
265
+ expect(resolveHeadingRotation(undefined)).toBeUndefined();
266
+ });
267
+ });
268
+
269
+ describe("resolveInitialCamera", () => {
270
+ const SIZE = { width: 400, height: 300 };
271
+
272
+ test("bounds win over a centre and zoom, framed for the view", () => {
273
+ const bounds = [174, -42, 176, -40] as const;
274
+ expect(resolveInitialCamera({ bounds, center: [0, 0], size: SIZE, zoom: 10 })).toEqual(
275
+ fitBoundsCamera(bounds, SIZE)
276
+ );
277
+ });
278
+
279
+ test("a centre and zoom, else the world at the default zoom", () => {
280
+ expect(resolveInitialCamera({ center: [174.776, -41.286], size: SIZE, zoom: 12 })).toEqual({
281
+ center: [174.776, -41.286],
282
+ zoom: 12,
283
+ });
284
+ expect(resolveInitialCamera({ size: SIZE })).toEqual({ center: [0, 0], zoom: MAP_DEFAULT_ZOOM });
285
+ });
286
+ });
287
+
288
+ describe("hasInitialViewChanged", () => {
289
+ test("a re-render with the same view in new arrays is not a change", () => {
290
+ expect(hasInitialViewChanged({ center: [1, 2], zoom: 3 }, { center: [1, 2], zoom: 3 })).toBe(false);
291
+ });
292
+
293
+ test("a new centre, zoom or box is", () => {
294
+ expect(hasInitialViewChanged({ center: [1, 2] }, { center: [1, 3] })).toBe(true);
295
+ expect(hasInitialViewChanged({ zoom: 3 }, { zoom: 4 })).toBe(true);
296
+ expect(hasInitialViewChanged({ bounds: [0, 0, 1, 1] }, { bounds: [0, 0, 1, 2] })).toBe(true);
297
+ });
298
+ });
299
+
300
+ describe("mapVariants", () => {
301
+ test("the map is labelled", () => {
302
+ expect(MAP_ACCESSIBILITY_LABEL).toBe("Map");
303
+ });
304
+
305
+ test("every corner pins the controls to it", () => {
306
+ const expected = {
307
+ "top-left": ["top-", "left-"],
308
+ "top-right": ["top-", "right-"],
309
+ "bottom-left": ["bottom-", "left-"],
310
+ "bottom-right": ["bottom-", "right-"],
311
+ } as const;
312
+ for (const corner of MAP_CONTROLS_CORNERS) {
313
+ const cls = mapVariants({ corner }).controls();
314
+ expect(cls).toContain("absolute");
315
+ for (const edge of expected[corner]) {
316
+ expect(cls).toContain(edge);
317
+ }
318
+ }
319
+ });
320
+
321
+ test("the label's tone and size land on its text, never its pill", () => {
322
+ for (const labelTone of MAP_LABEL_TONES) {
323
+ for (const labelSize of MAP_LABEL_SIZES) {
324
+ for (const labelSide of MAP_LABEL_SIDES) {
325
+ const slots = mapVariants({ labelSide, labelSize, labelTone });
326
+ expect(slots.label()).not.toMatch(TEXT_COLOR);
327
+ expect(slots.labelText()).toMatch(TEXT_COLOR);
328
+ }
329
+ }
330
+ }
331
+ expect(mapVariants({ labelTone: "primary" }).labelText()).toContain("text-primary");
332
+ expect(mapVariants({ labelTone: "muted" }).labelText()).toContain("text-muted-foreground");
333
+ });
334
+
335
+ test("the label keeps its distance from the pin on the side it sits", () => {
336
+ expect(mapVariants({ labelSide: "bottom" }).label()).toContain("mt-");
337
+ expect(mapVariants({ labelSide: "top" }).label()).toContain("mb-");
338
+ });
339
+
340
+ test("no view slot carries a text colour (rule 1)", () => {
341
+ for (const corner of MAP_CONTROLS_CORNERS) {
342
+ const slots = mapVariants({ corner });
343
+ for (const cls of [slots.root(), slots.controls(), slots.marker(), slots.popup(), slots.popupCaret()]) {
344
+ expect(cls).not.toMatch(TEXT_COLOR);
345
+ }
346
+ }
347
+ });
348
+
349
+ test("touches pass through the overlay layer to the map", () => {
350
+ expect(mapVariants().overlay()).toContain("absolute");
351
+ expect(mapVariants().overlay()).toContain("inset-0");
352
+ });
353
+ });