@sjvair/map-sdk 0.1.0-beta.7 → 0.1.0-beta.9

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.
package/README.md CHANGED
@@ -129,7 +129,13 @@ buttons, `"engine"` the MapTiler SDK's default zoom/compass and locate-me contro
129
129
  monitor-map uses today), and `"none"` none; the credits show in every case. Choose `controls` up
130
130
  front: changing it after mount recreates the map. `tooltip` is `"pinned"` (default: anchored to the
131
131
  feature) or `"pointer"` (shown at the pointer, as sjvair.com's map does, and hidden on zoom until
132
- the pointer moves again); a feature focused from the list is always pinned. Anything else goes in
132
+ the pointer moves again); a feature focused from the list is always pinned. `card` (default
133
+ `false`) pins the reading card on a click or tap: the card takes the pointer, has a Close button,
134
+ and closes with Esc, Close, or a click elsewhere on the map; focusing a feature-list row shows the
135
+ same card, and Tab from the row reaches it. Esc closes the card of the map that has focus; with
136
+ focus nowhere, it closes the page's most recently pinned card. One card shows at a time, and no hover card shows while
137
+ one is pinned. Selection and `featureclick` are unchanged: a click still selects as before and
138
+ also pins. `map.pinned` is the pinned feature (reactive), and `map.unpin()` closes it. Anything else goes in
133
139
  the `overlays` snippet, where `getMapContext()` returns the map. `config.basemaps` replaces the
134
140
  default MapTiler Streets v4 variants (build on `defaultBasemaps()`, which `createMap` uses: today's
135
141
  sjvair.com setup is `{ ...defaultBasemaps(), excludedLayers: [] }`); `config.initialView` and
@@ -558,6 +564,25 @@ local sjvair.com stack at `http://localhost:8000`.
558
564
 
559
565
  ## Changes
560
566
 
567
+ ### 0.1.0-beta.9
568
+
569
+ - **A map with `tooltip="pointer"` comes back after releasing WebGL.** Setting `MapView`'s
570
+ `active` to `false` threw "Cannot read properties of null (reading 'off')" from the tooltip's
571
+ zoom listener, and the map never drew again until a reload. It now releases and rebuilds cleanly.
572
+ In 0.1.0-beta.7 and 0.1.0-beta.8; maps with the default `tooltip="pinned"` weren't affected.
573
+
574
+ ### 0.1.0-beta.8
575
+
576
+ - **The pinned reading card.** `MapView`'s new `card` prop: a click or tap on a feature pins its
577
+ card, which takes the pointer, has a Close button, and closes with Esc, Close, or a click
578
+ elsewhere on the map. Focusing a feature-list row shows the same card, and Tab reaches it; closing
579
+ it returns focus where it came from. With `card`, a tap always shows the reading: without
580
+ it, a tap's emulated `mouseout` (Chromium, WebKit) still hides the hover card right after the
581
+ tap, as before. Nothing changes without `card`, and with it, selection and `featureclick` behave
582
+ as before.
583
+ - **New API:** `MapView`'s `card`, `MapContext.pinned` and `unpin()`. New message key for hosts
584
+ with their own catalog: `mapsdk_card_close` ("Close").
585
+
561
586
  ### 0.1.0-beta.7
562
587
 
563
588
  - **Plugins come back at once after a basemap switch.** After picking another basemap, monitors,
@@ -170,6 +170,17 @@ export interface MapContext {
170
170
  * lists, with the bubble's `properties`, never a feature beneath it.
171
171
  */
172
172
  readonly hovered: Hovered | null;
173
+ /**
174
+ * Reactive. The feature whose reading card is pinned, and where, while `MapView`'s `card` is
175
+ * on; `null` otherwise. A click on a feature pins it (selection and `featureclick` are
176
+ * unchanged), focusing a feature-list row pins that row's feature, and a click on nothing (or
177
+ * on a cluster bubble or a feature without a hover card) unpins.
178
+ */
179
+ readonly pinned: Hovered | null;
180
+ /** Closes the pinned card. Does nothing when nothing is pinned. */
181
+ unpin(): void;
182
+ /** @internal For `MapView` only: turns pinning on or off. Off also unpins. */
183
+ setCardEnabled(on: boolean): void;
173
184
  /**
174
185
  * Marks the feature focused and pans to it (no animation under reduced motion): the item in
175
186
  * `pluginId`'s features when given, else the first plugin listing `refKey`. `null` clears the
@@ -58,6 +58,8 @@ class MapContextImpl {
58
58
  #focusedKey = $state(null);
59
59
  #focusedPluginId = $state(null);
60
60
  #focusedAnchor = $state.raw(null);
61
+ #pinned = $state.raw(null);
62
+ #cardEnabled = false;
61
63
  #selections = new SvelteMap();
62
64
  #featureClickHandlers = new Set();
63
65
  #router = new InteractionRouter();
@@ -98,6 +100,7 @@ class MapContextImpl {
98
100
  }
99
101
  });
100
102
  this.#router.onPick((e) => this.#pick(e));
103
+ this.#router.onMiss(() => this.unpin());
101
104
  }
102
105
  get camera() {
103
106
  return this.#camera;
@@ -137,6 +140,18 @@ class MapContextImpl {
137
140
  get hovered() {
138
141
  return this.#router.hovered;
139
142
  }
143
+ get pinned() {
144
+ return this.#pinned;
145
+ }
146
+ unpin() {
147
+ if (untrack(() => this.#pinned) !== null)
148
+ this.#pinned = null;
149
+ }
150
+ setCardEnabled(on) {
151
+ this.#cardEnabled = on;
152
+ if (!on)
153
+ this.unpin();
154
+ }
140
155
  addPlugin(plugin, options = {}) {
141
156
  this.#assertAlive();
142
157
  const previous = untrack(() => this.#selections.get(plugin.id));
@@ -171,6 +186,8 @@ class MapContextImpl {
171
186
  removePlugin(id) {
172
187
  this.#host.remove(id);
173
188
  this.#selections.delete(id);
189
+ if (untrack(() => this.#pinned)?.hit.pluginId === id)
190
+ this.unpin();
174
191
  }
175
192
  setSelection(pluginId, selection) {
176
193
  if (this.#destroyed)
@@ -249,9 +266,15 @@ class MapContextImpl {
249
266
  const found = untrack(() => this.#findItem(refKey, pluginId));
250
267
  if (!found)
251
268
  return;
252
- const { item } = found;
269
+ const { plugin, item } = found;
253
270
  const place = this.#placeFor(item, gl);
254
271
  this.#focusedAnchor = place.anchor;
272
+ if (this.#cardEnabled) {
273
+ this.#pinned = {
274
+ hit: { pluginId: plugin.id, ref: item.ref, refKey: item.refKey, name: item.name },
275
+ lngLat: place.anchor
276
+ };
277
+ }
255
278
  if (!place.pan) {
256
279
  // Stays where it is now: an earlier focus's ease that is still running stops here.
257
280
  // With nothing running, no move at all.
@@ -395,6 +418,7 @@ class MapContextImpl {
395
418
  return;
396
419
  this.#gl.off("moveend", this.#onMoveEnd);
397
420
  this.#focusEasing = false;
421
+ this.unpin();
398
422
  this.#router.detach();
399
423
  this.basemap.detach();
400
424
  this.#host.detach();
@@ -437,9 +461,18 @@ class MapContextImpl {
437
461
  #pick(e) {
438
462
  if (this.#destroyed)
439
463
  return;
440
- const [{ pluginId, ref }] = e.hits;
464
+ const [hit] = e.hits;
465
+ const { pluginId, ref } = hit;
441
466
  this.#emitClick(pluginId, ref, e.lngLat);
442
467
  this.#applyPick(pluginId, ref, e.additive);
468
+ if (!this.#cardEnabled || this.#destroyed)
469
+ return;
470
+ // A feature with no hover card (e.g. a smoke plume) gets no pinned card either.
471
+ const plugin = untrack(() => this.plugins).find((p) => p.id === pluginId);
472
+ if (!plugin || plugin.hoverable === false)
473
+ this.unpin();
474
+ else
475
+ this.#pinned = { hit, lngLat: e.lngLat };
443
476
  }
444
477
  #emitClick(pluginId, ref, lngLat) {
445
478
  for (const handler of [...this.#featureClickHandlers]) {
@@ -42,4 +42,9 @@ export declare class InteractionRouter {
42
42
  * click, or a touch tap; selection rules decide what it means for the plugin's mode.
43
43
  */
44
44
  onPick(handler: (e: ClickEvent) => void): () => void;
45
+ /**
46
+ * Called for a click that picks nothing: one with no hits, or one on a blocking layer (after
47
+ * that layer's own click handler). Returns an unsubscribe function.
48
+ */
49
+ onMiss(handler: () => void): () => void;
45
50
  }
@@ -14,6 +14,7 @@ export class InteractionRouter {
14
14
  /* eslint-disable svelte/prefer-svelte-reactivity */
15
15
  #registrations = new Set();
16
16
  #pickHandlers = new Set();
17
+ #missHandlers = new Set();
17
18
  /* eslint-enable svelte/prefer-svelte-reactivity */
18
19
  #gl = null;
19
20
  #canvas = null;
@@ -186,6 +187,26 @@ export class InteractionRouter {
186
187
  this.#pickHandlers.delete(handler);
187
188
  };
188
189
  }
190
+ /**
191
+ * Called for a click that picks nothing: one with no hits, or one on a blocking layer (after
192
+ * that layer's own click handler). Returns an unsubscribe function.
193
+ */
194
+ onMiss(handler) {
195
+ this.#missHandlers.add(handler);
196
+ return () => {
197
+ this.#missHandlers.delete(handler);
198
+ };
199
+ }
200
+ #emitMiss() {
201
+ for (const handler of [...this.#missHandlers]) {
202
+ try {
203
+ handler();
204
+ }
205
+ catch (error) {
206
+ console.error("map-sdk: a miss handler failed", error);
207
+ }
208
+ }
209
+ }
189
210
  #onPointerDown = (e) => {
190
211
  this.#lastPointerType = e.pointerType || null;
191
212
  };
@@ -201,11 +222,14 @@ export class InteractionRouter {
201
222
  catch (error) {
202
223
  console.error("map-sdk: a blocking layer's click handler failed", error);
203
224
  }
225
+ this.#emitMiss();
204
226
  return;
205
227
  }
206
228
  const { hits } = found;
207
- if (hits.length === 0)
229
+ if (hits.length === 0) {
230
+ this.#emitMiss();
208
231
  return;
232
+ }
209
233
  const o = e.originalEvent;
210
234
  const pointerType = o?.pointerType || recorded;
211
235
  const event = {
@@ -56,5 +56,6 @@
56
56
  "mapsdk_collocation_reference": "Reference monitor",
57
57
  "mapsdk_collocation_colocated": "Collocated monitor",
58
58
  "mapsdk_collocation_interval_avg": "({interval} avg)",
59
- "mapsdk_collocation_updated": "Last updated {when}"
59
+ "mapsdk_collocation_updated": "Last updated {when}",
60
+ "mapsdk_card_close": "Close"
60
61
  }
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { untrack } from "svelte";
3
+ import type { Attachment } from "svelte/attachments";
3
4
  import type { MapContext } from "../core/create-map.svelte.js";
4
5
  import type { GlMapLike } from "../core/gl.js";
5
6
  import type { FeatureListItem, MapPlugin } from "../core/plugin.js";
@@ -24,17 +25,27 @@
24
25
  mode?: "pinned" | "pointer";
25
26
  /** The GL map, for `zoomstart` in `"pointer"` mode. */
26
27
  gl?: Pick<GlMapLike, "on" | "off"> | null;
28
+ /**
29
+ * The pinned reading card: `map.pinned` and a focused list item show as an interactive card
30
+ * (pointer events, a Close button, Esc), and the hover doesn't show while one is pinned.
31
+ */
32
+ card?: boolean;
27
33
  }
28
34
 
29
- let { map, project, width, height, mode = "pinned", gl = null }: Props = $props();
35
+ let { map, project, width, height, mode = "pinned", gl = null, card = false }: Props = $props();
36
+
37
+ // Ignored without the card, so a host's map never pins.
38
+ const pinned = $derived(card ? map.pinned : null);
30
39
 
31
40
  // Pointer mode only: set by a zoom, cleared by the next pointer move over a hit.
32
41
  let zoomHidden = $state(false);
33
42
  $effect(() => {
34
- if (mode !== "pointer" || !gl) return;
43
+ // Captured: props are live, and the cleanup runs after MapView has set `gl` to null.
44
+ const g = gl;
45
+ if (mode !== "pointer" || !g) return;
35
46
  const onZoomStart = () => (zoomHidden = true);
36
- gl.on("zoomstart", onZoomStart);
37
- return () => gl.off("zoomstart", onZoomStart);
47
+ g.on("zoomstart", onZoomStart);
48
+ return () => g.off("zoomstart", onZoomStart);
38
49
  });
39
50
  $effect(() => {
40
51
  // A new hovered object comes with every pointer move over a hit.
@@ -45,11 +56,15 @@
45
56
  let tipHeight = $state(0);
46
57
  const GAP = 12;
47
58
 
48
- // The keyboard-focused list item wins over the pointer.
49
- const key = $derived(map.focusedKey ?? map.hovered?.hit.refKey ?? null);
59
+ // The keyboard-focused list item wins, then the pinned card, then the pointer (which shows
60
+ // nothing while a card is pinned).
61
+ const shownHit = $derived(map.focusedKey ? null : (pinned ?? map.hovered));
62
+ const key = $derived(map.focusedKey ?? shownHit?.hit.refKey ?? null);
50
63
  // Primitives, so moving the pointer within one feature doesn't recompute the content.
51
- const hitPluginId = $derived(map.focusedKey ? null : (map.hovered?.hit.pluginId ?? null));
52
- const hitName = $derived(map.focusedKey ? null : (map.hovered?.hit.name ?? null));
64
+ const hitPluginId = $derived(shownHit?.hit.pluginId ?? null);
65
+ const hitName = $derived(shownHit?.hit.name ?? null);
66
+ // The card shows (interactive) for a focused item or a pinned feature.
67
+ const interactive = $derived(card && (map.focusedKey !== null || pinned !== null));
53
68
 
54
69
  // Reads only the shown feature's plugin (or all plugins for a focused key), and only while
55
70
  // something is shown, so an idle map doesn't subscribe to any plugin's data.
@@ -83,12 +98,20 @@
83
98
  return untrack(() => (item?.geometry ? map.anchorFor(item) : null));
84
99
  });
85
100
  const anchor = $derived.by((): [number, number] | null => {
86
- if (mode === "pointer" && !map.focusedKey) return map.hovered?.lngLat ?? null;
101
+ if (mode === "pointer" && !map.focusedKey && !pinned) return map.hovered?.lngLat ?? null;
87
102
  // A focused feature that left the data has no item, so no tooltip.
88
103
  if (map.focusedKey) return item ? (map.focusedAnchor ?? item.lngLat) : null;
89
- return hoverAnchor ?? item?.lngLat ?? map.hovered?.lngLat ?? null;
104
+ return hoverAnchor ?? item?.lngLat ?? shownHit?.lngLat ?? null;
90
105
  });
91
106
  const projected = $derived(anchor ? project(anchor) : null);
107
+ // A card whose anchor is out of the map's box hides until it's back.
108
+ const offScreen = $derived(
109
+ interactive &&
110
+ projected !== null &&
111
+ width !== undefined &&
112
+ height !== undefined &&
113
+ (projected.x < 0 || projected.y < 0 || projected.x > width || projected.y > height)
114
+ );
92
115
  // Centered above the point; flipped below it near the top edge; inside the container.
93
116
  const position = $derived.by(() => {
94
117
  if (!projected) return null;
@@ -105,7 +128,7 @@
105
128
  // A hovered hit no plugin lists (e.g. a cluster bubble): its plugin's hover content.
106
129
  if (key === null || hitPluginId === null) return undefined;
107
130
  const plugin = map.plugins.find((p) => p.id === hitPluginId);
108
- const hit = untrack(() => map.hovered?.hit);
131
+ const hit = untrack(() => shownHit?.hit);
109
132
  return hit && plugin?.hoverTooltip?.(hit);
110
133
  });
111
134
 
@@ -116,10 +139,128 @@
116
139
  const plugin = found?.plugin ?? map.plugins.find((p) => p.id === hitPluginId);
117
140
  return !plugin?.tooltipShowsName;
118
141
  });
142
+
143
+ let cardEl: HTMLDivElement | undefined = $state();
144
+ /** Where focus came from when it moved into the card, to return it on close. */
145
+ let origin: HTMLElement | null = null;
146
+
147
+ function onfocusin(e: FocusEvent) {
148
+ const from = e.relatedTarget;
149
+ focusInside = true;
150
+ region = cardEl?.closest<HTMLElement>("[data-mapsdk-map]") ?? region;
151
+ // Focus from outside sets the origin afresh, even from nowhere, so an earlier card's
152
+ // origin is never reused.
153
+ if (!(from instanceof Node && cardEl?.contains(from))) {
154
+ origin = from instanceof HTMLElement ? from : null;
155
+ }
156
+ }
157
+
158
+ /** Focus is inside the card (kept by focusin/focusout, since a removed card no longer says). */
159
+ let focusInside = false;
160
+ /** The map region the card is in, found while it's in the document. */
161
+ let region: HTMLElement | null = null;
162
+
163
+ function onfocusout(e: FocusEvent) {
164
+ const to = e.relatedTarget;
165
+ if (to instanceof Node && cardEl?.contains(to)) return;
166
+ // Later, not now: removing the focused card fires focusout too, and its teardown (which
167
+ // runs first) still has to see that focus was inside.
168
+ queueMicrotask(() => {
169
+ if (!cardEl?.contains(document.activeElement)) focusInside = false;
170
+ });
171
+ }
172
+
173
+ /** Focus inside the card goes back where it came from, else to the map region. */
174
+ function handBack() {
175
+ if (!focusInside) return;
176
+ focusInside = false;
177
+ const back = origin;
178
+ origin = null;
179
+ if (back?.isConnected) back.focus();
180
+ else if (region?.isConnected) {
181
+ if (!region.hasAttribute("tabindex")) region.setAttribute("tabindex", "-1");
182
+ region.focus();
183
+ }
184
+ }
185
+
186
+ // However the card goes (Close, a host's unpin, its plugin removed, its anchor off-screen),
187
+ // focus inside it isn't left on a removed node.
188
+ const handBackOnRemove: Attachment<HTMLElement> = () => () => handBack();
189
+
190
+ /** Unpins, handing focus back first. */
191
+ function close() {
192
+ handBack();
193
+ map.unpin();
194
+ // Focus back on a list row focuses (and so shows) that row again; the card stays closed
195
+ // until the next row move, as with Esc in the list.
196
+ if (map.focusedKey !== null) map.focusFeature(null);
197
+ }
198
+
199
+ function onkeydown(e: KeyboardEvent) {
200
+ if (e.key !== "Escape") return;
201
+ e.preventDefault();
202
+ e.stopPropagation();
203
+ close();
204
+ }
205
+
206
+ // A pinned hit with nothing to show (no name, listed item, or hover content) would be an
207
+ // invisible card that still hides the hover and takes Esc: unpin it.
208
+ $effect(() => {
209
+ if (pinned && !map.focusedKey && !(name || item || custom)) untrack(() => map.unpin());
210
+ });
211
+
212
+ // A pinned feature that left its plugin's data (a poll dropped it) closes its card. Only one
213
+ // its plugin listed while pinned: some hits are never listed (HMS fire hotspots), and keep it.
214
+ let listedKey: string | null = null;
215
+ $effect(() => {
216
+ if (!pinned || map.focusedKey) return;
217
+ const { pluginId, refKey } = pinned.hit;
218
+ const plugin = map.plugins.find((p) => p.id === pluginId);
219
+ if (!plugin?.features) return;
220
+ const id = `${pluginId}\n${refKey}`;
221
+ if (plugin.features().some((f) => f.refKey === refKey)) listedKey = id;
222
+ else if (listedKey === id) {
223
+ listedKey = null;
224
+ untrack(close);
225
+ }
226
+ });
119
227
  </script>
120
228
 
121
229
  <!-- Only with something to show: a hit with no name, item, or content gets no empty box. -->
122
- {#if (name || item || custom) && position && !(zoomHidden && !map.focusedKey)}
230
+ {#if interactive && (name || item || custom) && position && !offScreen}
231
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
232
+ <div
233
+ bind:this={cardEl}
234
+ {@attach handBackOnRemove}
235
+ class="mapsdk-tooltip mapsdk-card text-sm"
236
+ role="group"
237
+ aria-label={name ?? undefined}
238
+ bind:offsetWidth={tipWidth}
239
+ bind:offsetHeight={tipHeight}
240
+ style:left="{position.left}px"
241
+ style:top="{position.top}px"
242
+ {onfocusin}
243
+ {onfocusout}
244
+ {onkeydown}
245
+ >
246
+ {#if showName}
247
+ <div class="mapsdk-tooltip-name">{name}</div>
248
+ {/if}
249
+ {#if custom}
250
+ {@render custom()}
251
+ {:else if item}
252
+ <TooltipItem {item} messages={map.messages} format={map.format} />
253
+ {/if}
254
+ <button
255
+ type="button"
256
+ class="mapsdk-card-close mapsdk-focus"
257
+ aria-label={map.messages("mapsdk_card_close")}
258
+ onclick={close}
259
+ >
260
+ <span aria-hidden="true">×</span>
261
+ </button>
262
+ </div>
263
+ {:else if !interactive && (name || item || custom) && position && !(zoomHidden && !map.focusedKey)}
123
264
  <div
124
265
  class="mapsdk-tooltip text-sm"
125
266
  role="tooltip"
@@ -168,4 +309,36 @@
168
309
  .mapsdk-tooltip-name {
169
310
  font-weight: 600;
170
311
  }
312
+
313
+ /* The pinned card: takes the pointer, and sits above the feature list panel. Room on the
314
+ right for the Close button. */
315
+ .mapsdk-card {
316
+ z-index: 3;
317
+ padding-right: 2rem;
318
+ pointer-events: auto;
319
+ }
320
+
321
+ .mapsdk-card-close {
322
+ position: absolute;
323
+ top: 0.25rem;
324
+ right: 0.25rem;
325
+ display: flex;
326
+ align-items: center;
327
+ justify-content: center;
328
+ width: 1.5rem;
329
+ height: 1.5rem;
330
+ padding: 0;
331
+ border: 0;
332
+ border-radius: var(--radius-control, var(--radius));
333
+ background: transparent;
334
+ color: inherit;
335
+ font: inherit;
336
+ font-size: 1.125rem;
337
+ line-height: 1;
338
+ cursor: pointer;
339
+ }
340
+
341
+ .mapsdk-card-close:hover {
342
+ background: var(--accent, var(--muted));
343
+ }
171
344
  </style>
@@ -22,6 +22,11 @@ interface Props {
22
22
  mode?: "pinned" | "pointer";
23
23
  /** The GL map, for `zoomstart` in `"pointer"` mode. */
24
24
  gl?: Pick<GlMapLike, "on" | "off"> | null;
25
+ /**
26
+ * The pinned reading card: `map.pinned` and a focused list item show as an interactive card
27
+ * (pointer events, a Close button, Esc), and the hover doesn't show while one is pinned.
28
+ */
29
+ card?: boolean;
25
30
  }
26
31
  declare const MapTooltip: import("svelte").Component<Props, {}, "">;
27
32
  type MapTooltip = ReturnType<typeof MapTooltip>;
@@ -56,6 +56,12 @@
56
56
  * Keyboard focus from the feature list always shows it pinned.
57
57
  */
58
58
  tooltip?: "pinned" | "pointer";
59
+ /**
60
+ * `true`: a click or tap on a feature pins its reading card, which takes the pointer and
61
+ * closes with Esc, its Close button, or a click elsewhere on the map; focusing a
62
+ * feature-list row shows the same card. Selection and `featureclick` are unchanged.
63
+ */
64
+ card?: boolean;
59
65
  }
60
66
 
61
67
  let {
@@ -67,9 +73,46 @@
67
73
  featureList = true,
68
74
  selectionBar = true,
69
75
  controls = "mapsdk",
70
- tooltip = "pinned"
76
+ tooltip = "pinned",
77
+ card = false
71
78
  }: Props = $props();
72
79
 
80
+ // Pinning only, no plugin setup, so it's safe in an effect.
81
+ $effect(() => {
82
+ map.setCardEnabled(card);
83
+ return () => map.setCardEnabled(false);
84
+ });
85
+
86
+ /**
87
+ * Esc outside the card closes it when focus is in this map or nowhere (the body), so a key
88
+ * pressed in another map, or elsewhere on the page, leaves this card alone.
89
+ */
90
+ function onWindowKeydown(e: KeyboardEvent) {
91
+ if (e.key !== "Escape" || e.defaultPrevented || !map.pinned) return;
92
+ const active = document.activeElement;
93
+ if (active && active !== document.body && !root.contains(active)) return;
94
+ // Focus nowhere: only the most recently pinned card on the page closes.
95
+ if (!active || active === document.body) {
96
+ let latest: HTMLElement | null = null;
97
+ for (const el of document.querySelectorAll<HTMLElement>("[data-mapsdk-pinned-at]")) {
98
+ if (!latest || Number(el.dataset.mapsdkPinnedAt) > Number(latest.dataset.mapsdkPinnedAt)) {
99
+ latest = el;
100
+ }
101
+ }
102
+ if (latest && latest !== root) return;
103
+ }
104
+ e.preventDefault();
105
+ map.unpin();
106
+ }
107
+
108
+ // When this map's card was pinned, so Esc with focus nowhere closes the latest card only.
109
+ $effect(() => {
110
+ const pinned = map.pinned;
111
+ if (!root) return;
112
+ if (pinned) root.dataset.mapsdkPinnedAt = String(performance.now());
113
+ else delete root.dataset.mapsdkPinnedAt;
114
+ });
115
+
73
116
  const list = $derived(resolveFeatureList(featureList));
74
117
  const sides = $derived(list ? cornerSides(list.position) : null);
75
118
 
@@ -148,6 +191,12 @@
148
191
  function onListKeydown(e: KeyboardEvent) {
149
192
  if (e.key !== "Escape") return;
150
193
  e.preventDefault();
194
+ // The card closes first (and the focused row's card with it); the next Esc closes the panel.
195
+ if (card && (map.pinned || map.focusedKey)) {
196
+ map.unpin();
197
+ map.focusFeature(null);
198
+ return;
199
+ }
151
200
  listOpen = false;
152
201
  listButton?.focus();
153
202
  }
@@ -242,6 +291,8 @@
242
291
  });
243
292
  </script>
244
293
 
294
+ <svelte:window onkeydown={onWindowKeydown} />
295
+
245
296
  <div
246
297
  bind:this={root}
247
298
  bind:clientWidth={width}
@@ -253,7 +304,6 @@
253
304
  style:--mapsdk-map-width={width > 0 ? `${width}px` : undefined}
254
305
  >
255
306
  <div class="mapsdk-gl" bind:this={glContainer}></div>
256
- <MapTooltip {map} {project} {width} {height} mode={tooltip} gl={projector} />
257
307
  {#if list && sides}
258
308
  <div
259
309
  class="mapsdk-list-panel"
@@ -283,6 +333,8 @@
283
333
  {/if}
284
334
  </div>
285
335
  {/if}
336
+ <!-- After the list, so Tab from a list row reaches the pinned card's Close button. -->
337
+ <MapTooltip {map} {project} {width} {height} mode={tooltip} gl={projector} {card} />
286
338
  <!-- After the list, so Tab reaches the List button first. -->
287
339
  {#if selectionBar}
288
340
  <SelectionBar {map} clearRight={list?.position === "top-right" && controls === "none"} />
@@ -37,6 +37,12 @@ interface Props {
37
37
  * Keyboard focus from the feature list always shows it pinned.
38
38
  */
39
39
  tooltip?: "pinned" | "pointer";
40
+ /**
41
+ * `true`: a click or tap on a feature pins its reading card, which takes the pointer and
42
+ * closes with Esc, its Close button, or a click elsewhere on the map; focusing a
43
+ * feature-list row shows the same card. Selection and `featureclick` are unchanged.
44
+ */
45
+ card?: boolean;
40
46
  }
41
47
  declare const MapView: import("svelte").Component<Props, {}, "">;
42
48
  type MapView = ReturnType<typeof MapView>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sjvair/map-sdk",
3
3
  "description": "Svelte 5 map components for SJVAir air-quality data",
4
- "version": "0.1.0-beta.7",
4
+ "version": "0.1.0-beta.9",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",