Skip to content

Commit 5b685f2

Browse files
committed
Address CodeRabbit review feedback
- Expose the details modal as a dialog: role/aria-modal/aria-label, move focus to its close button on open, and restore focus to the opener on close, so keyboard and screen-reader users are not left behind the overlay. - Tear the panel down in deactivate() as well as in the render cleanup. Both panel APIs are optional-chained, so the host may never invoke the cleanup, leaving the store subscription alive and letting a later setEarthdataGisLabels remount into a detached container. - Resolve a web map's per-layer visibility lookups concurrently instead of once per sequential add, so N unresponsive image layers cost one timeout rather than N. The store writes stay ordered to preserve layer order. - Drop the unreachable revealRasterLayer(bbox, null) branch after a web map add; with no zoom constraint it always fits the bounds and returns false, and the status was overwritten on the next line either way.
1 parent e026e51 commit 5b685f2

1 file changed

Lines changed: 68 additions & 17 deletions

File tree

packages/plugins/src/plugins/maplibre-earthdata-gis.ts

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -373,25 +373,40 @@ async function addToMap(item: EarthdataGisItem, onHint?: (hint: string) => void)
373373
await addWebMapToMap(item, onHint);
374374
return;
375375
}
376-
await addServiceToMap(item, onHint);
376+
await addServiceToMap(item, { onHint });
377+
}
378+
379+
/** Options for {@link addServiceToMap}. */
380+
interface AddServiceOptions {
381+
/** Called when the view had to zoom past the extent to show data. */
382+
onHint?: (hint: string) => void;
383+
/**
384+
* Set when the layer comes from a web map, so it can be tracked and removed
385+
* together with its siblings.
386+
*/
387+
webMapId?: string;
388+
/** Whether to move the view to the new layer. @default true */
389+
fit?: boolean;
390+
/**
391+
* An already-resolved minimum visible zoom, letting a caller that adds many
392+
* layers run the lookups concurrently. Omit to look it up here; `null` means
393+
* the lookup ran and found no constraint.
394+
*/
395+
minVisibleZoom?: number | null;
377396
}
378397

379398
/**
380399
* Adds one ArcGIS service as a layer and returns its store id.
381400
*
382401
* @param item - A service item (never a web map)
383-
* @param onHint - Called when the view had to zoom past the extent to show data
384-
* @param webMapId - Set when the layer comes from a web map, so it can be
385-
* tracked and removed together with its siblings
386-
* @param fit - Whether to move the view to the new layer
402+
* @param options - Hint sink, web map ownership, view and zoom-lookup control
387403
* @returns The new layer's store id, or null when the item yields no layer
388404
*/
389405
async function addServiceToMap(
390406
item: EarthdataGisItem,
391-
onHint?: (hint: string) => void,
392-
webMapId?: string,
393-
fit = true,
407+
options: AddServiceOptions = {},
394408
): Promise<string | null> {
409+
const { onHint, webMapId, fit = true, minVisibleZoom: presetMinVisibleZoom } = options;
395410
const ownerMetadata = webMapId ? { earthdataWebMapId: webMapId } : undefined;
396411

397412
if (item.kind === "feature") {
@@ -414,7 +429,12 @@ async function addServiceToMap(
414429

415430
// Best-effort: a service that never answers must not block the add, so the
416431
// lookup is bounded and any failure simply leaves the layer unconstrained.
417-
const minVisibleZoom = await withVisibilityTimeout(fetchMinVisibleZoom(item));
432+
// `undefined` means "not looked up yet"; `null` is a completed lookup that
433+
// found no constraint, so only the former triggers a fetch here.
434+
const minVisibleZoom =
435+
presetMinVisibleZoom !== undefined
436+
? presetMinVisibleZoom
437+
: await withVisibilityTimeout(fetchMinVisibleZoom(item));
418438

419439
const layerId = useAppStore.getState().addTileLayer(item.title, {
420440
type: layerTypeForTiles([tileUrl]),
@@ -453,15 +473,25 @@ async function addWebMapToMap(
453473
const renderable = Array.isArray(layers) ? (layers as WebMapLayer[]) : [];
454474
if (renderable.length === 0) throw new Error(labels.webMapEmpty);
455475

476+
const children = renderable.map((layer, index) => webMapLayerAsItem(item, layer, index));
477+
// Each raster child's visibility lookup is a bounded pair of requests, so
478+
// running them together keeps a web map of N unresponsive image layers from
479+
// costing N x VISIBILITY_LOOKUP_TIMEOUT_MS. Only the store writes below need
480+
// to stay ordered.
481+
const minVisibleZooms = await Promise.all(
482+
children.map((child) =>
483+
child.kind === "image" ? withVisibilityTimeout(fetchMinVisibleZoom(child)) : null,
484+
),
485+
);
486+
456487
const addedIds: string[] = [];
457-
for (const [index, layer] of renderable.entries()) {
488+
for (const [index, child] of children.entries()) {
458489
try {
459-
const id = await addServiceToMap(
460-
webMapLayerAsItem(item, layer, index),
461-
undefined,
462-
item.id,
463-
false,
464-
);
490+
const id = await addServiceToMap(child, {
491+
webMapId: item.id,
492+
fit: false,
493+
minVisibleZoom: minVisibleZooms[index],
494+
});
465495
if (id) addedIds.push(id);
466496
} catch {
467497
// One unreachable layer must not abandon the rest of the web map; the
@@ -471,7 +501,9 @@ async function addWebMapToMap(
471501
if (addedIds.length === 0) throw new Error(labels.webMapEmpty);
472502

473503
appRef?.addLayerGroup?.(item.title, addedIds);
474-
if (item.bbox && revealRasterLayer(item.bbox, null)) onHint?.(labels.zoomedToData);
504+
// A web map is a curated composition, so its own extent is the right view;
505+
// the per-layer zoom-to-data rule would over-zoom for its other layers.
506+
if (item.bbox) appRef?.fitBounds?.(item.bbox);
475507
onHint?.(labels.webMapAdded(addedIds.length, renderable.length));
476508
}
477509

@@ -597,12 +629,19 @@ function openDetailsModal(item: EarthdataGisItem): void {
597629
closeDetailsDialog?.();
598630

599631
const overlay = document.createElement("div");
632+
// Captured before the dialog steals focus so closing can hand it back to the
633+
// card button that opened it, instead of dumping the user at the page top.
634+
const previouslyFocused = document.activeElement as HTMLElement | null;
600635
overlay.style.cssText =
601636
"position:fixed;inset:0;z-index:2147483000;display:flex;" +
602637
"align-items:center;justify-content:center;padding:16px;" +
603638
"background:rgba(0,0,0,0.5);";
604639

605640
const dialog = document.createElement("div");
641+
dialog.setAttribute("role", "dialog");
642+
dialog.setAttribute("aria-modal", "true");
643+
dialog.setAttribute("aria-label", labels.detailsHeading);
644+
dialog.tabIndex = -1;
606645
dialog.style.cssText =
607646
"display:flex;flex-direction:column;width:100%;max-width:560px;" +
608647
"max-height:80vh;border-radius:8px;overflow:hidden;" +
@@ -679,6 +718,7 @@ function openDetailsModal(item: EarthdataGisItem): void {
679718
const close = (): void => {
680719
overlay.remove();
681720
document.removeEventListener("keydown", onKey);
721+
previouslyFocused?.focus?.();
682722
if (closeDetailsDialog === close) closeDetailsDialog = null;
683723
};
684724
const onKey = (event: KeyboardEvent): void => {
@@ -690,6 +730,9 @@ function openDetailsModal(item: EarthdataGisItem): void {
690730
closeButton.addEventListener("click", close);
691731
document.addEventListener("keydown", onKey);
692732
document.body.appendChild(overlay);
733+
// Move focus inside so the dialog is reachable by keyboard and announced;
734+
// without this, Tab keeps walking the panel behind the overlay.
735+
closeButton.focus();
693736
closeDetailsDialog = close;
694737
}
695738

@@ -1083,6 +1126,14 @@ export const maplibreEarthdataGisPlugin: GeoLibrePlugin = {
10831126
app.closeRightPanel?.(PANEL_ID);
10841127
unregisterPanel?.();
10851128
unregisterPanel = null;
1129+
// Both panel APIs above are optional-chained, so the host may never invoke
1130+
// the render cleanup. Tear the panel down here too, or its store
1131+
// subscription outlives deactivation and a later setEarthdataGisLabels
1132+
// remounts into a detached container. Already-run cleanup leaves
1133+
// disposePanel null, so this is a no-op in the normal case.
1134+
disposePanel?.();
1135+
disposePanel = null;
1136+
panelContainer = null;
10861137
closeDetailsDialog?.();
10871138
pendingAdds.clear();
10881139
appRef = null;

0 commit comments

Comments
 (0)