@elabs-ai/components-maps 4.1.0 → 5.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 (291) hide show
  1. package/README.md +1 -1
  2. package/dist/chunk-2FPB53NG.js +36 -0
  3. package/dist/chunk-2FPB53NG.js.map +1 -0
  4. package/dist/chunk-2VVJRT66.js +37 -0
  5. package/dist/chunk-2VVJRT66.js.map +1 -0
  6. package/dist/chunk-3HXFONRP.js +2 -0
  7. package/dist/chunk-3HXFONRP.js.map +1 -0
  8. package/dist/chunk-3SJN3IYT.js +2 -0
  9. package/dist/chunk-3SJN3IYT.js.map +1 -0
  10. package/dist/chunk-4DANBD5B.js +55 -0
  11. package/dist/chunk-4DANBD5B.js.map +1 -0
  12. package/dist/chunk-5PDVG53B.js +2 -0
  13. package/dist/chunk-5PDVG53B.js.map +1 -0
  14. package/dist/chunk-643R7IJC.js +2 -0
  15. package/dist/chunk-643R7IJC.js.map +1 -0
  16. package/dist/chunk-B3ZDBQ6I.js +164 -0
  17. package/dist/chunk-B3ZDBQ6I.js.map +1 -0
  18. package/dist/chunk-BGCE3RR5.js +35 -0
  19. package/dist/chunk-BGCE3RR5.js.map +1 -0
  20. package/dist/chunk-C4VI37TD.js +485 -0
  21. package/dist/chunk-C4VI37TD.js.map +1 -0
  22. package/dist/chunk-CJJT6AEI.js +179 -0
  23. package/dist/chunk-CJJT6AEI.js.map +1 -0
  24. package/dist/chunk-D2RCPTRO.js +2 -0
  25. package/dist/chunk-D2RCPTRO.js.map +1 -0
  26. package/dist/chunk-DTV7JZVN.js +2 -0
  27. package/dist/chunk-DTV7JZVN.js.map +1 -0
  28. package/dist/chunk-E65Z3MSV.js +80 -0
  29. package/dist/chunk-E65Z3MSV.js.map +1 -0
  30. package/dist/chunk-E7VPNINZ.js +70 -0
  31. package/dist/chunk-E7VPNINZ.js.map +1 -0
  32. package/dist/chunk-FHV5IA6N.js +100 -0
  33. package/dist/chunk-FHV5IA6N.js.map +1 -0
  34. package/dist/chunk-FQ6V7BFZ.js +347 -0
  35. package/dist/chunk-FQ6V7BFZ.js.map +1 -0
  36. package/dist/chunk-G7JDXDJN.js +19 -0
  37. package/dist/chunk-G7JDXDJN.js.map +1 -0
  38. package/dist/chunk-GWULZOMW.js +21 -0
  39. package/dist/chunk-GWULZOMW.js.map +1 -0
  40. package/dist/chunk-GYDTTAVD.js +120 -0
  41. package/dist/chunk-GYDTTAVD.js.map +1 -0
  42. package/dist/chunk-HCOBWIM4.js +48 -0
  43. package/dist/chunk-HCOBWIM4.js.map +1 -0
  44. package/dist/chunk-IAHK5MVT.js +2 -0
  45. package/dist/chunk-IAHK5MVT.js.map +1 -0
  46. package/dist/chunk-JGJ6442I.js +235 -0
  47. package/dist/chunk-JGJ6442I.js.map +1 -0
  48. package/dist/chunk-JYFERXZT.js +18 -0
  49. package/dist/chunk-JYFERXZT.js.map +1 -0
  50. package/dist/chunk-LU2UEKFM.js +53 -0
  51. package/dist/chunk-LU2UEKFM.js.map +1 -0
  52. package/dist/chunk-LVYM6C5C.js +2 -0
  53. package/dist/chunk-LVYM6C5C.js.map +1 -0
  54. package/dist/chunk-MIMDP3EU.js +104 -0
  55. package/dist/chunk-MIMDP3EU.js.map +1 -0
  56. package/dist/chunk-NNBURJHY.js +2 -0
  57. package/dist/chunk-NNBURJHY.js.map +1 -0
  58. package/dist/chunk-NPUC3V37.js +2 -0
  59. package/dist/chunk-NPUC3V37.js.map +1 -0
  60. package/dist/chunk-OEVVNPN3.js +201 -0
  61. package/dist/chunk-OEVVNPN3.js.map +1 -0
  62. package/dist/chunk-PF3R2BXM.js +200 -0
  63. package/dist/chunk-PF3R2BXM.js.map +1 -0
  64. package/dist/chunk-Q2I5YODW.js +2 -0
  65. package/dist/chunk-Q2I5YODW.js.map +1 -0
  66. package/dist/chunk-QDWWP3N7.js +247 -0
  67. package/dist/chunk-QDWWP3N7.js.map +1 -0
  68. package/dist/chunk-QU33KPMP.js +2 -0
  69. package/dist/chunk-QU33KPMP.js.map +1 -0
  70. package/dist/chunk-RK4BUMHS.js +2 -0
  71. package/dist/chunk-RK4BUMHS.js.map +1 -0
  72. package/dist/chunk-RNSLG64F.js +26 -0
  73. package/dist/chunk-RNSLG64F.js.map +1 -0
  74. package/dist/chunk-RV673TB4.js +68 -0
  75. package/dist/chunk-RV673TB4.js.map +1 -0
  76. package/dist/chunk-S7N2WMVT.js +180 -0
  77. package/dist/chunk-S7N2WMVT.js.map +1 -0
  78. package/dist/chunk-T3Z6TCDD.js +2 -0
  79. package/dist/chunk-T3Z6TCDD.js.map +1 -0
  80. package/dist/chunk-T4TJTVVA.js +105 -0
  81. package/dist/chunk-T4TJTVVA.js.map +1 -0
  82. package/dist/chunk-UMOYRCDX.js +103 -0
  83. package/dist/chunk-UMOYRCDX.js.map +1 -0
  84. package/dist/chunk-V2REM6UT.js +185 -0
  85. package/dist/chunk-V2REM6UT.js.map +1 -0
  86. package/dist/chunk-VFBRFGID.js +89 -0
  87. package/dist/chunk-VFBRFGID.js.map +1 -0
  88. package/dist/chunk-W76NLFER.js +103 -0
  89. package/dist/chunk-W76NLFER.js.map +1 -0
  90. package/dist/chunk-WNU4IOGL.js +116 -0
  91. package/dist/chunk-WNU4IOGL.js.map +1 -0
  92. package/dist/chunk-WS4Z7ENS.js +494 -0
  93. package/dist/chunk-WS4Z7ENS.js.map +1 -0
  94. package/dist/chunk-XQT7YLJY.js +497 -0
  95. package/dist/chunk-XQT7YLJY.js.map +1 -0
  96. package/dist/chunk-Y44MEKJV.js +21 -0
  97. package/dist/chunk-Y44MEKJV.js.map +1 -0
  98. package/dist/chunk-Z4EPSOB4.js +53 -0
  99. package/dist/chunk-Z4EPSOB4.js.map +1 -0
  100. package/dist/chunk-Z5OPDBDC.js +2 -0
  101. package/dist/chunk-Z5OPDBDC.js.map +1 -0
  102. package/dist/index.d.ts +1241 -46
  103. package/dist/index.js +189 -1456
  104. package/dist/index.js.map +1 -1
  105. package/dist/lib/arc-math.js +8 -0
  106. package/dist/lib/arc-math.js.map +1 -0
  107. package/dist/lib/map-position.js +11 -0
  108. package/dist/lib/map-position.js.map +1 -0
  109. package/dist/lib/merge-hover-paint.js +10 -0
  110. package/dist/lib/merge-hover-paint.js.map +1 -0
  111. package/dist/lib/plan-crs.js +14 -0
  112. package/dist/lib/plan-crs.js.map +1 -0
  113. package/dist/lib/plan-patterns.js +14 -0
  114. package/dist/lib/plan-patterns.js.map +1 -0
  115. package/dist/lib/plan-regions.js +18 -0
  116. package/dist/lib/plan-regions.js.map +1 -0
  117. package/dist/lib/plan-status.js +14 -0
  118. package/dist/lib/plan-status.js.map +1 -0
  119. package/dist/lib/route-arrow.js +12 -0
  120. package/dist/lib/route-arrow.js.map +1 -0
  121. package/dist/lib/use-map-breakpoint.js +33 -0
  122. package/dist/lib/use-map-breakpoint.js.map +1 -0
  123. package/dist/lib/use-plan-projection.js +9 -0
  124. package/dist/lib/use-plan-projection.js.map +1 -0
  125. package/dist/lib/use-token-color.js +10 -0
  126. package/dist/lib/use-token-color.js.map +1 -0
  127. package/dist/lib/warn-once.js +10 -0
  128. package/dist/lib/warn-once.js.map +1 -0
  129. package/dist/map-annotation/anchor.js +14 -0
  130. package/dist/map-annotation/anchor.js.map +1 -0
  131. package/dist/map-annotation/index.js +21 -0
  132. package/dist/map-annotation/index.js.map +1 -0
  133. package/dist/map-annotation/map-annotation.js +18 -0
  134. package/dist/map-annotation/map-annotation.js.map +1 -0
  135. package/dist/map-arc/index.js +16 -0
  136. package/dist/map-arc/index.js.map +1 -0
  137. package/dist/map-arc/map-arc.js +13 -0
  138. package/dist/map-arc/map-arc.js.map +1 -0
  139. package/dist/map-canvas/index.css +11 -0
  140. package/dist/map-canvas/index.css.map +1 -0
  141. package/dist/map-canvas/index.js +20 -0
  142. package/dist/map-canvas/index.js.map +1 -0
  143. package/dist/map-canvas/map-canvas.css +11 -0
  144. package/dist/map-canvas/map-canvas.css.map +1 -0
  145. package/dist/map-canvas/map-canvas.js +17 -0
  146. package/dist/map-canvas/map-canvas.js.map +1 -0
  147. package/dist/map-canvas/map-context.js +11 -0
  148. package/dist/map-canvas/map-context.js.map +1 -0
  149. package/dist/map-canvas/use-resolved-basemap-theme.js +9 -0
  150. package/dist/map-canvas/use-resolved-basemap-theme.js.map +1 -0
  151. package/dist/map-cluster-layer/index.js +12 -0
  152. package/dist/map-cluster-layer/index.js.map +1 -0
  153. package/dist/map-cluster-layer/map-cluster-layer.js +12 -0
  154. package/dist/map-cluster-layer/map-cluster-layer.js.map +1 -0
  155. package/dist/map-controls/index.js +11 -0
  156. package/dist/map-controls/index.js.map +1 -0
  157. package/dist/map-controls/map-controls.js +11 -0
  158. package/dist/map-controls/map-controls.js.map +1 -0
  159. package/dist/map-geojson/index.js +13 -0
  160. package/dist/map-geojson/index.js.map +1 -0
  161. package/dist/map-geojson/map-geojson.js +13 -0
  162. package/dist/map-geojson/map-geojson.js.map +1 -0
  163. package/dist/map-inset/index.css +11 -0
  164. package/dist/map-inset/index.css.map +1 -0
  165. package/dist/map-inset/index.js +20 -0
  166. package/dist/map-inset/index.js.map +1 -0
  167. package/dist/map-inset/map-inset.css +11 -0
  168. package/dist/map-inset/map-inset.css.map +1 -0
  169. package/dist/map-inset/map-inset.js +20 -0
  170. package/dist/map-inset/map-inset.js.map +1 -0
  171. package/dist/map-legend/index.js +14 -0
  172. package/dist/map-legend/index.js.map +1 -0
  173. package/dist/map-legend/map-corner.js +10 -0
  174. package/dist/map-legend/map-corner.js.map +1 -0
  175. package/dist/map-legend/map-legend.js +11 -0
  176. package/dist/map-legend/map-legend.js.map +1 -0
  177. package/dist/map-marker/index.js +22 -0
  178. package/dist/map-marker/index.js.map +1 -0
  179. package/dist/map-marker/map-marker.js +22 -0
  180. package/dist/map-marker/map-marker.js.map +1 -0
  181. package/dist/map-north-arrow/index.js +11 -0
  182. package/dist/map-north-arrow/index.js.map +1 -0
  183. package/dist/map-north-arrow/map-north-arrow.js +11 -0
  184. package/dist/map-north-arrow/map-north-arrow.js.map +1 -0
  185. package/dist/map-plan/index.js +30 -0
  186. package/dist/map-plan/index.js.map +1 -0
  187. package/dist/map-plan/map-plan-legend.js +10 -0
  188. package/dist/map-plan/map-plan-legend.js.map +1 -0
  189. package/dist/map-plan/map-plan-overlay.js +13 -0
  190. package/dist/map-plan/map-plan-overlay.js.map +1 -0
  191. package/dist/map-plan/map-plan-status.js +9 -0
  192. package/dist/map-plan/map-plan-status.js.map +1 -0
  193. package/dist/map-plan/map-plan-table.js +10 -0
  194. package/dist/map-plan/map-plan-table.js.map +1 -0
  195. package/dist/map-plan/use-plan-selection.js +9 -0
  196. package/dist/map-plan/use-plan-selection.js.map +1 -0
  197. package/dist/map-plan-image/index.js +11 -0
  198. package/dist/map-plan-image/index.js.map +1 -0
  199. package/dist/map-plan-image/map-plan-image.js +11 -0
  200. package/dist/map-plan-image/map-plan-image.js.map +1 -0
  201. package/dist/map-popup/index.js +12 -0
  202. package/dist/map-popup/index.js.map +1 -0
  203. package/dist/map-popup/map-popup.js +12 -0
  204. package/dist/map-popup/map-popup.js.map +1 -0
  205. package/dist/map-route/index.js +12 -0
  206. package/dist/map-route/index.js.map +1 -0
  207. package/dist/map-route/map-route.js +12 -0
  208. package/dist/map-route/map-route.js.map +1 -0
  209. package/dist/map-scale-bar/index.js +18 -0
  210. package/dist/map-scale-bar/index.js.map +1 -0
  211. package/dist/map-scale-bar/map-scale-bar.js +18 -0
  212. package/dist/map-scale-bar/map-scale-bar.js.map +1 -0
  213. package/package.json +5 -5
  214. package/src/index.ts +83 -0
  215. package/src/lib/arc-math.test.ts +19 -0
  216. package/src/lib/arc-math.ts +6 -1
  217. package/src/lib/map-position.ts +77 -0
  218. package/src/lib/merge-hover-paint.ts +44 -13
  219. package/src/lib/plan-crs.parity.test.ts +39 -0
  220. package/src/lib/plan-crs.test.ts +248 -0
  221. package/src/lib/plan-crs.ts +252 -0
  222. package/src/lib/plan-patterns.test.ts +255 -0
  223. package/src/lib/plan-patterns.ts +160 -0
  224. package/src/lib/plan-regions.test.ts +186 -0
  225. package/src/lib/plan-regions.ts +209 -0
  226. package/src/lib/plan-status.test.ts +87 -0
  227. package/src/lib/plan-status.ts +147 -0
  228. package/src/lib/route-arrow.test.ts +88 -0
  229. package/src/lib/route-arrow.ts +59 -0
  230. package/src/lib/use-map-breakpoint.test.ts +83 -0
  231. package/src/lib/use-map-breakpoint.ts +238 -0
  232. package/src/lib/use-plan-projection.ts +157 -0
  233. package/src/lib/warn-once.ts +21 -0
  234. package/src/map-annotation/anchor.ts +92 -0
  235. package/src/map-annotation/index.ts +15 -0
  236. package/src/map-annotation/map-annotation.stories.tsx +113 -0
  237. package/src/map-annotation/map-annotation.test.tsx +106 -0
  238. package/src/map-annotation/map-annotation.tsx +356 -0
  239. package/src/map-arc/map-arc.tsx +26 -5
  240. package/src/map-canvas/map-canvas.stories.tsx +211 -0
  241. package/src/map-canvas/map-canvas.test.tsx +421 -1
  242. package/src/map-canvas/map-canvas.tsx +466 -20
  243. package/src/map-canvas/map-context.ts +11 -0
  244. package/src/map-cluster-layer/map-cluster-layer.tsx +14 -1
  245. package/src/map-controls/map-controls.stories.tsx +16 -0
  246. package/src/map-controls/map-controls.test.tsx +145 -1
  247. package/src/map-controls/map-controls.tsx +57 -12
  248. package/src/map-geojson/map-geojson.stories.tsx +153 -3
  249. package/src/map-geojson/map-geojson.test.tsx +453 -0
  250. package/src/map-geojson/map-geojson.tsx +591 -51
  251. package/src/map-inset/index.ts +1 -0
  252. package/src/map-inset/map-inset.stories.tsx +61 -0
  253. package/src/map-inset/map-inset.test.tsx +117 -0
  254. package/src/map-inset/map-inset.tsx +264 -0
  255. package/src/map-legend/index.ts +10 -0
  256. package/src/map-legend/map-corner.ts +23 -0
  257. package/src/map-legend/map-legend.stories.tsx +171 -0
  258. package/src/map-legend/map-legend.test.tsx +136 -0
  259. package/src/map-legend/map-legend.tsx +264 -0
  260. package/src/map-marker/map-marker.stories.tsx +136 -4
  261. package/src/map-marker/map-marker.test.tsx +383 -3
  262. package/src/map-marker/map-marker.tsx +414 -44
  263. package/src/map-north-arrow/index.ts +1 -0
  264. package/src/map-north-arrow/map-north-arrow.stories.tsx +65 -0
  265. package/src/map-north-arrow/map-north-arrow.test.tsx +39 -0
  266. package/src/map-north-arrow/map-north-arrow.tsx +82 -0
  267. package/src/map-plan/index.ts +13 -0
  268. package/src/map-plan/map-plan-legend.test.tsx +61 -0
  269. package/src/map-plan/map-plan-legend.tsx +83 -0
  270. package/src/map-plan/map-plan-overlay.stories.tsx +260 -0
  271. package/src/map-plan/map-plan-overlay.test.tsx +302 -0
  272. package/src/map-plan/map-plan-overlay.tsx +531 -0
  273. package/src/map-plan/map-plan-status.test.tsx +71 -0
  274. package/src/map-plan/map-plan-status.tsx +77 -0
  275. package/src/map-plan/map-plan-table.test.tsx +86 -0
  276. package/src/map-plan/map-plan-table.tsx +153 -0
  277. package/src/map-plan/use-plan-selection.ts +78 -0
  278. package/src/map-plan-image/index.ts +1 -0
  279. package/src/map-plan-image/map-plan-image.stories.tsx +301 -0
  280. package/src/map-plan-image/map-plan-image.test.tsx +176 -0
  281. package/src/map-plan-image/map-plan-image.tsx +279 -0
  282. package/src/map-popup/map-popup.tsx +18 -11
  283. package/src/map-route/index.ts +1 -1
  284. package/src/map-route/map-route.test.tsx +216 -0
  285. package/src/map-route/map-route.tsx +105 -6
  286. package/src/map-scale-bar/index.ts +9 -0
  287. package/src/map-scale-bar/map-scale-bar.stories.tsx +63 -0
  288. package/src/map-scale-bar/map-scale-bar.test.tsx +65 -0
  289. package/src/map-scale-bar/map-scale-bar.tsx +145 -0
  290. package/src/test-utils/locator-fixture.ts +75 -0
  291. package/src/test-utils/maplibre-mock.ts +267 -20
@@ -1,4 +1,4 @@
1
- import { cleanup, render, waitFor } from "@testing-library/react";
1
+ import { act, cleanup, render, waitFor } from "@testing-library/react";
2
2
  import { afterEach, describe, expect, it, vi } from "vitest";
3
3
 
4
4
  vi.mock("maplibre-gl", async () => {
@@ -6,9 +6,9 @@ vi.mock("maplibre-gl", async () => {
6
6
  return createMaplibreMock();
7
7
  });
8
8
 
9
- import { MockMarker, resetMaplibreMock } from "../test-utils/maplibre-mock";
9
+ import { MockMap, MockMarker, MockPopup, resetMaplibreMock } from "../test-utils/maplibre-mock";
10
10
  import { MapCanvas } from "../map-canvas";
11
- import { MapMarker, MapMarkerContent, MapMarkerLabel } from "./map-marker";
11
+ import { MapMarker, MapMarkerContent, MapMarkerLabel, MapMarkerTooltip } from "./map-marker";
12
12
 
13
13
  afterEach(() => {
14
14
  cleanup();
@@ -64,6 +64,41 @@ describe("MapMarker", () => {
64
64
  });
65
65
  });
66
66
 
67
+ it("opens its tooltip on focus and Escape, not only under a pointer", async () => {
68
+ // MapLibre gives a marker a tab stop only inside `setPopup`, and binds no
69
+ // key handler at all — so a hover-only tooltip is unreachable (WCAG 2.1.1)
70
+ // and undismissable (1.4.13). The tooltip asks for both itself.
71
+ render(
72
+ <MapCanvas>
73
+ <MapMarker longitude={13.4} latitude={52.52}>
74
+ <MapMarkerContent />
75
+ <MapMarkerTooltip>Berlin</MapMarkerTooltip>
76
+ </MapMarker>
77
+ </MapCanvas>,
78
+ );
79
+ await waitFor(() => expect(MockPopup.instances).toHaveLength(1));
80
+ const element = MockMarker.instances[0]!.getElement();
81
+ const tooltip = MockPopup.instances[0]!;
82
+ expect(element.getAttribute("tabindex")).toBe("0");
83
+
84
+ expect(tooltip.isOpen()).toBe(false);
85
+ element.dispatchEvent(new FocusEvent("focus"));
86
+ expect(tooltip.isOpen()).toBe(true);
87
+ element.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
88
+ expect(tooltip.isOpen()).toBe(false);
89
+
90
+ element.dispatchEvent(new FocusEvent("focus"));
91
+ expect(tooltip.isOpen()).toBe(true);
92
+ element.dispatchEvent(new FocusEvent("blur"));
93
+ expect(tooltip.isOpen()).toBe(false);
94
+
95
+ // The pointer path is untouched.
96
+ element.dispatchEvent(new MouseEvent("mouseenter"));
97
+ expect(tooltip.isOpen()).toBe(true);
98
+ element.dispatchEvent(new MouseEvent("mouseleave"));
99
+ expect(tooltip.isOpen()).toBe(false);
100
+ });
101
+
67
102
  it("removes the marker on unmount", async () => {
68
103
  const { unmount } = render(
69
104
  <MapCanvas>
@@ -78,4 +113,349 @@ describe("MapMarker", () => {
78
113
  unmount();
79
114
  expect(MockMarker.instances[0]!.addedTo).toBeNull();
80
115
  });
116
+
117
+ it("rebuilds the marker instance when `anchor` changes — MapLibre has no live setter for it", async () => {
118
+ const { rerender } = render(
119
+ <MapCanvas>
120
+ <MapMarker longitude={0} latitude={0} anchor="top">
121
+ <MapMarkerContent />
122
+ </MapMarker>
123
+ </MapCanvas>,
124
+ );
125
+ await waitFor(() => {
126
+ expect(MockMarker.instances).toHaveLength(1);
127
+ expect(MockMarker.instances[0]!.options.anchor).toBe("top");
128
+ });
129
+
130
+ rerender(
131
+ <MapCanvas>
132
+ <MapMarker longitude={0} latitude={0} anchor="bottom">
133
+ <MapMarkerContent />
134
+ </MapMarker>
135
+ </MapCanvas>,
136
+ );
137
+
138
+ await waitFor(() => {
139
+ expect(MockMarker.instances).toHaveLength(2);
140
+ expect(MockMarker.instances[1]!.options.anchor).toBe("bottom");
141
+ });
142
+ // The old instance is detached, not mutated in place.
143
+ expect(MockMarker.instances[0]!.addedTo).toBeNull();
144
+ expect(MockMarker.instances[1]!.addedTo).not.toBeNull();
145
+ });
146
+
147
+ it("never touches `document` during the render phase (SSR-safe)", async () => {
148
+ // `renderToStaticMarkup` never runs effects — only the plain render
149
+ // functions — which is exactly the environment a real SSR host runs in.
150
+ // Deleting `document` reproduces that host having none: the old
151
+ // `useMemo`-based marker construction called `document.createElement`
152
+ // straight out of the render function and would throw here.
153
+ const { renderToStaticMarkup } = await import("react-dom/server");
154
+ const originalDocument = globalThis.document;
155
+ // @ts-expect-error -- simulating a server environment with no DOM
156
+ delete globalThis.document;
157
+ try {
158
+ expect(() =>
159
+ renderToStaticMarkup(
160
+ <MapCanvas>
161
+ <MapMarker longitude={0} latitude={0}>
162
+ <MapMarkerContent />
163
+ </MapMarker>
164
+ </MapCanvas>,
165
+ ),
166
+ ).not.toThrow();
167
+ } finally {
168
+ globalThis.document = originalDocument;
169
+ }
170
+ });
171
+
172
+ it("keeps a marker hidden at the current tier off the map (showAt)", async () => {
173
+ const spy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({
174
+ width: 348,
175
+ height: 0,
176
+ top: 0,
177
+ left: 0,
178
+ right: 348,
179
+ bottom: 0,
180
+ x: 0,
181
+ y: 0,
182
+ toJSON: () => ({}),
183
+ });
184
+ render(
185
+ <MapCanvas>
186
+ <MapMarker longitude={1} latitude={1} showAt={{ base: true, narrow: false }}>
187
+ <MapMarkerContent />
188
+ </MapMarker>
189
+ <MapMarker longitude={2} latitude={2}>
190
+ <MapMarkerContent />
191
+ </MapMarker>
192
+ </MapCanvas>,
193
+ );
194
+ await waitFor(() => {
195
+ expect(MockMarker.instances).toHaveLength(2);
196
+ expect(MockMarker.instances[1]!.addedTo).not.toBeNull();
197
+ });
198
+ expect(MockMarker.instances[0]!.addedTo).toBeNull();
199
+ spy.mockRestore();
200
+ });
201
+
202
+ it("draws its own label at one of eight positions, boxed, with a callout", async () => {
203
+ render(
204
+ <MapCanvas>
205
+ <MapMarker
206
+ longitude={0}
207
+ latitude={0}
208
+ label={{ text: "Hamilton", position: "bottom-left", box: true, callout: true }}
209
+ />
210
+ </MapCanvas>,
211
+ );
212
+ await waitFor(() => expect(MockMarker.instances).toHaveLength(1));
213
+ const el = MockMarker.instances[0]!.element;
214
+ const label = await waitFor(() => {
215
+ const found = el.querySelector<HTMLElement>('[data-slot="map-marker-label"]');
216
+ expect(found).not.toBeNull();
217
+ return found!;
218
+ });
219
+ expect(label).toHaveTextContent("Hamilton");
220
+ expect(label).toHaveAttribute("data-position", "bottom-left");
221
+ expect(label).toHaveClass("bg-background/90");
222
+ expect(label.style.transform).toContain("translate(calc(-100% +");
223
+ expect(el.querySelector('[data-slot="map-marker-callout"] line')).not.toBeNull();
224
+ });
225
+ });
226
+
227
+ describe("MapMarker — a draggable marker answers the keyboard (c-4)", () => {
228
+ /** Fires a real key press on the marker element. */
229
+ function press(element: HTMLElement, key: string, shiftKey = false) {
230
+ element.dispatchEvent(new KeyboardEvent("keydown", { key, shiftKey, bubbles: true }));
231
+ element.dispatchEvent(new KeyboardEvent("keyup", { key, shiftKey, bubbles: true }));
232
+ }
233
+
234
+ it("takes a tab stop, names its drag and steps in pixels on the arrow keys", async () => {
235
+ const onDragStart = vi.fn();
236
+ const onDrag = vi.fn();
237
+ const onDragEnd = vi.fn();
238
+ render(
239
+ <MapCanvas>
240
+ <MapMarker
241
+ draggable
242
+ latitude={52}
243
+ longitude={13}
244
+ onDrag={onDrag}
245
+ onDragEnd={onDragEnd}
246
+ onDragStart={onDragStart}
247
+ >
248
+ <MapMarkerContent />
249
+ </MapMarker>
250
+ </MapCanvas>,
251
+ );
252
+ await waitFor(() => expect(MockMarker.instances).toHaveLength(1));
253
+ const marker = MockMarker.instances[0]!;
254
+ const element = marker.getElement();
255
+
256
+ // WCAG 2.1.1: MapLibre gives a marker a tab stop only inside `setPopup`,
257
+ // so a popup-less draggable marker had neither one nor a key handler.
258
+ expect(element.getAttribute("tabindex")).toBe("0");
259
+ expect(element.getAttribute("role")).toBe("button");
260
+ expect(element.getAttribute("aria-label")).toMatch(/arrow keys/i);
261
+
262
+ // The mock projects 1° to 10 px, so one 8 px step east is +0.8°.
263
+ press(element, "ArrowRight");
264
+ expect(marker.lngLat.lng).toBeCloseTo(13.8, 5);
265
+ expect(onDragStart).toHaveBeenCalledTimes(1);
266
+ expect(onDrag).toHaveBeenCalledTimes(1);
267
+ expect(onDragEnd).toHaveBeenCalledTimes(1);
268
+ const settled = onDragEnd.mock.lastCall?.[0] as { lng: number; lat: number };
269
+ expect(settled.lng).toBeCloseTo(13.8, 5);
270
+ expect(settled.lat).toBeCloseTo(52, 5);
271
+
272
+ // Shift is the coarse step (40 px = 4°), and north is a SMALLER y.
273
+ press(element, "ArrowUp", true);
274
+ expect(marker.lngLat.lat).toBeCloseTo(56, 5);
275
+ });
276
+
277
+ it("names what it moves when the caller says so, and leaves a static marker alone", async () => {
278
+ const { rerender } = render(
279
+ <MapCanvas>
280
+ <MapMarker draggable dragLabel="Depot location" latitude={0} longitude={0}>
281
+ <MapMarkerContent />
282
+ </MapMarker>
283
+ </MapCanvas>,
284
+ );
285
+ await waitFor(() => expect(MockMarker.instances).toHaveLength(1));
286
+ const element = MockMarker.instances[0]!.getElement();
287
+ expect(element.getAttribute("aria-label")).toBe("Depot location");
288
+
289
+ // Not draggable → no tab stop and no invented button role.
290
+ rerender(
291
+ <MapCanvas>
292
+ <MapMarker latitude={0} longitude={0}>
293
+ <MapMarkerContent />
294
+ </MapMarker>
295
+ </MapCanvas>,
296
+ );
297
+ await waitFor(() => expect(element.getAttribute("tabindex")).toBeNull());
298
+ expect(element.getAttribute("role")).toBeNull();
299
+ });
300
+
301
+ it("steps the marker the right way by the right amount on every arrow key, and never lets the map's own keyboard pan handler see the press (c-4 fix round 2)", async () => {
302
+ render(
303
+ <MapCanvas>
304
+ <MapMarker draggable latitude={0} longitude={0}>
305
+ <MapMarkerContent />
306
+ </MapMarker>
307
+ </MapCanvas>,
308
+ );
309
+ await waitFor(() => expect(MockMarker.instances).toHaveLength(1));
310
+ const map = MockMap.instances[0]!;
311
+ const marker = MockMarker.instances[0]!;
312
+ const element = marker.getElement();
313
+
314
+ // Real MapLibre appends a marker's element straight into
315
+ // `map.getCanvasContainer()` — the SAME element its own `HandlerManager`
316
+ // listens on for `keydown` (bubble phase, no `defaultPrevented` check),
317
+ // where it runs its own arrow-key camera pan. The mock doesn't wire this
318
+ // parentage up on its own, so this test does, by hand, to stand in for
319
+ // that handler and prove ours never lets it see the key.
320
+ const canvasContainer = map.getCanvasContainer();
321
+ canvasContainer.appendChild(element);
322
+ const cameraPanSpy = vi.fn();
323
+ canvasContainer.addEventListener("keydown", cameraPanSpy);
324
+
325
+ // The mock projects 1° to 10 px: an 8 px fine step is 0.8°, a 40 px
326
+ // coarse (Shift) step is 4°.
327
+ press(element, "ArrowRight");
328
+ expect(marker.lngLat.lng).toBeCloseTo(0.8, 5);
329
+ press(element, "ArrowLeft");
330
+ expect(marker.lngLat.lng).toBeCloseTo(0, 5);
331
+ press(element, "ArrowUp");
332
+ expect(marker.lngLat.lat).toBeCloseTo(0.8, 5); // north = a LARGER lat
333
+ press(element, "ArrowDown");
334
+ expect(marker.lngLat.lat).toBeCloseTo(0, 5);
335
+
336
+ press(element, "ArrowRight", true);
337
+ expect(marker.lngLat.lng).toBeCloseTo(4, 5);
338
+ press(element, "ArrowLeft", true);
339
+ expect(marker.lngLat.lng).toBeCloseTo(0, 5);
340
+ press(element, "ArrowUp", true);
341
+ expect(marker.lngLat.lat).toBeCloseTo(4, 5);
342
+ press(element, "ArrowDown", true);
343
+ expect(marker.lngLat.lat).toBeCloseTo(0, 5);
344
+
345
+ // The map centre never moved, and its own keyboard pan handler never
346
+ // even saw one of these eight presses — a missing `stopPropagation()`
347
+ // is exactly what let it fire and pan the CAMERA the opposite way.
348
+ expect(cameraPanSpy).not.toHaveBeenCalled();
349
+ expect(map.getCenter()).toEqual({ lng: 0, lat: 0 });
350
+ });
351
+ });
352
+
353
+ describe("MapMarker — a label stays inside the map box (c-7, c-8)", () => {
354
+ /** jsdom measures nothing, so both boxes are described explicitly. */
355
+ function stubRect(element: Element, left: number, top: number, width: number, height: number) {
356
+ element.getBoundingClientRect = () =>
357
+ ({
358
+ left,
359
+ top,
360
+ width,
361
+ height,
362
+ right: left + width,
363
+ bottom: top + height,
364
+ x: left,
365
+ y: top,
366
+ toJSON: () => ({}),
367
+ }) as DOMRect;
368
+ }
369
+
370
+ async function renderLabel(longitude: number, latitude: number) {
371
+ render(
372
+ <MapCanvas>
373
+ <MapMarker
374
+ longitude={longitude}
375
+ latitude={latitude}
376
+ label={{ text: "bottom-right", position: "bottom-right" }}
377
+ />
378
+ </MapCanvas>,
379
+ );
380
+ await waitFor(() => expect(MockMarker.instances).toHaveLength(1));
381
+ const map = MockMap.instances[0]!;
382
+ const label = await waitFor(() => {
383
+ const found = MockMarker.instances[0]!.element.querySelector<HTMLElement>(
384
+ '[data-slot="map-marker-label"]',
385
+ );
386
+ expect(found).not.toBeNull();
387
+ return found!;
388
+ });
389
+ // A 364 x 480 map box — the width the reviewer measured at 380 px.
390
+ stubRect(map.getContainer(), 0, 0, 364, 480);
391
+ return { map, label };
392
+ }
393
+
394
+ it("slides a label that would cross the map's edge back inside", async () => {
395
+ // The mock projects 1° to 10 px: [-160, 50] lands at (200, 400), inside.
396
+ const { map, label } = await renderLabel(-160, 50);
397
+ // The measured overflow: the glyph run ran to x = 368.12 in a box 364 wide.
398
+ stubRect(label, 296.89, 169.13, 71.23, 15);
399
+ act(() => map.emit("move"));
400
+ const shift = Number.parseFloat(label.style.translate);
401
+ expect(shift).toBeCloseTo(-8.12, 2);
402
+ expect(296.89 + shift + 71.23).toBeLessThanOrEqual(364);
403
+ });
404
+
405
+ it("leaves a label that already fits exactly where it was", async () => {
406
+ const { map, label } = await renderLabel(-160, 50);
407
+ stubRect(label, 120, 200, 71.23, 15);
408
+ act(() => map.emit("move"));
409
+ expect(label.style.translate).toBe("");
410
+ });
411
+
412
+ it("hides a label whose own point has left the map box", async () => {
413
+ // [0, 0] projects to (1800, 900) — far outside a 364 x 480 box. A name
414
+ // pinned to the edge beside no marker claims a place you cannot see.
415
+ const { map, label } = await renderLabel(0, 0);
416
+ stubRect(label, 1700, 880, 71.23, 15);
417
+ act(() => map.emit("move"));
418
+ expect(label.style.visibility).toBe("hidden");
419
+ });
420
+
421
+ it("clamps a composed MapMarkerLabel too", async () => {
422
+ render(
423
+ <MapCanvas>
424
+ <MapMarker longitude={-160} latitude={50}>
425
+ <MapMarkerContent />
426
+ <MapMarkerLabel position="bottom">Brandenburg Gate</MapMarkerLabel>
427
+ </MapMarker>
428
+ </MapCanvas>,
429
+ );
430
+ await waitFor(() => expect(MockMarker.instances).toHaveLength(1));
431
+ const map = MockMap.instances[0]!;
432
+ const label = await waitFor(() => {
433
+ const found = MockMarker.instances[0]!.element.querySelector<HTMLElement>(
434
+ '[data-slot="map-marker-label"]',
435
+ );
436
+ expect(found).not.toBeNull();
437
+ return found!;
438
+ });
439
+ stubRect(map.getContainer(), 0, 0, 380, 480);
440
+ // The measured rect at 380 px: 54.43 px of the glyph run outside the start edge.
441
+ stubRect(label, -54.43, 294.29, 104.39, 15);
442
+ act(() => map.emit("move"));
443
+ const shift = Number.parseFloat(label.style.translate);
444
+ expect(shift).toBeCloseTo(58.43, 2);
445
+ expect(-54.43 + shift).toBeGreaterThanOrEqual(4);
446
+ });
447
+
448
+ // c-10: a marker MapLibre makes focusable (a popup, a keyboard drag) wore
449
+ // the browser's own focus ring instead of the theme's.
450
+ it("gives the marker element the house focus ring", async () => {
451
+ render(
452
+ <MapCanvas>
453
+ <MapMarker longitude={-160} latitude={50}>
454
+ <MapMarkerContent />
455
+ </MapMarker>
456
+ </MapCanvas>,
457
+ );
458
+ await waitFor(() => expect(MockMarker.instances).toHaveLength(1));
459
+ expect(MockMarker.instances[0]!.element.classList.contains("focus-ring")).toBe(true);
460
+ });
81
461
  });