shadimage
nordaun
blockmapsource: ok · hit

Map

A modular context-based map with built in controls such as projection, zoom, rotation and fullscreen.

npx shadcn add "https://ui.nordaun.com/r/map.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

buttonbutton-group

Files2 · source cached 10/14/2026, 11:24:41 AM

src/components/ui/loading.tsx
import { cn } from "@/lib/utils";

function Loading({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      className={cn(
        "absolute inset-0 z-10 flex items-center justify-center bg-background/50 backdrop-blur-xs",
        className,
      )}
      {...props}
    >
      <div className="flex gap-1">
        <span className="size-1.5 animate-pulse rounded-full bg-primary/75" />
        <span className="size-1.5 animate-pulse rounded-full bg-primary/75 [animation-delay:150ms]" />
        <span className="size-1.5 animate-pulse rounded-full bg-primary/75 [animation-delay:300ms]" />
      </div>
    </div>
  );
}

export { Loading };
src/components/ui/map.tsx
"use client";

import {
  MapLibreMap,
  MapOptions,
  Marker,
  MarkerOptions,
  Popup,
  PopupOptions,
  StyleSpecification,
} from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import * as React from "react";
import { createPortal } from "react-dom";

import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import { Loading } from "@/components/ui/loading";
import { cn } from "@/lib/utils";
import {
  ChevronDown,
  ChevronUp,
  Globe,
  Loader2,
  Locate,
  Map as MapIcon,
  MapPin,
  Maximize,
  Minimize,
  Minus,
  Plus,
} from "lucide-react";

type Theme = "light" | "dark";
type Coordinate = { latitude: number; longitude: number };
type Projection = "mercator" | "globe";
type Style = string | StyleSpecification;

const defaultStyles: Record<Theme, Style> = {
  light: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
  dark: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
};

function getDocumentTheme(): Theme | null {
  if (typeof document === "undefined") return null;
  if (document.documentElement.classList.contains("dark")) return "dark";
  if (document.documentElement.classList.contains("light")) return "light";
  return null;
}

function getSystemTheme(): Theme {
  if (typeof window === "undefined") return "light";
  return window.matchMedia("(prefers-color-scheme: dark)").matches
    ? "dark"
    : "light";
}

function useTheme(defaultTheme?: Theme): Theme {
  const [theme, setTheme] = React.useState<Theme>(
    () => getDocumentTheme() ?? getSystemTheme(),
  );

  React.useEffect(() => {
    if (defaultTheme) return;

    const observer = new MutationObserver(() => {
      const docTheme = getDocumentTheme();
      if (docTheme) setTheme(docTheme);
    });

    observer.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });

    const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
    const handleSystemChange = (e: MediaQueryListEvent) => {
      if (!getDocumentTheme()) setTheme(e.matches ? "dark" : "light");
    };
    mediaQuery.addEventListener("change", handleSystemChange);

    return () => {
      observer.disconnect();
      mediaQuery.removeEventListener("change", handleSystemChange);
    };
  }, [defaultTheme]);

  return defaultTheme ?? theme;
}

type MapContext = {
  mapRef: React.RefObject<MapLibreMap | null>;
  containerRef: React.RefObject<HTMLDivElement | null>;
  loaded: boolean;
  setLoaded: React.Dispatch<React.SetStateAction<boolean>>;
  projection: Projection;
  setProjection: React.Dispatch<React.SetStateAction<Projection>>;
  center: Coordinate;
  setCenter: React.Dispatch<React.SetStateAction<Coordinate>>;
  zoom: number;
  setZoom: React.Dispatch<React.SetStateAction<number>>;
  bearing: number;
  setBearing: React.Dispatch<React.SetStateAction<number>>;
  pitch: number;
  setPitch: React.Dispatch<React.SetStateAction<number>>;
  fullscreen: boolean;
  setFullscreen: React.Dispatch<React.SetStateAction<boolean>>;
};

const DefaultMapContext: MapContext = {
  mapRef: { current: null },
  containerRef: { current: null },
  loaded: false,
  setLoaded: () => {},
  projection: "mercator",
  setProjection: () => {},
  center: { latitude: 47.4979, longitude: 19.0404 },
  setCenter: () => {},
  zoom: 1,
  setZoom: () => {},
  bearing: 0,
  setBearing: () => {},
  pitch: 0,
  setPitch: () => {},
  fullscreen: false,
  setFullscreen: () => {},
};

const Context = React.createContext<MapContext>(DefaultMapContext);

type MapProviderProps = {
  defaultCenter?: Coordinate;
  defaultZoom?: number;
  defaultBearing?: number;
  defaultPitch?: number;
  defaultProjection?: Projection;
  children: React.ReactNode;
};

function MapProvider({
  defaultCenter = { latitude: 47.4979, longitude: 19.0404 },
  defaultZoom = 8,
  defaultBearing = 0,
  defaultPitch = 0,
  defaultProjection = "mercator",
  children,
}: MapProviderProps) {
  const mapRef = React.useRef<MapLibreMap>(null);
  const containerRef = React.useRef<HTMLDivElement>(null);

  const [loaded, setLoaded] = React.useState<boolean>(false);
  const [zoom, setZoom] = React.useState<number>(defaultZoom);
  const [bearing, setBearing] = React.useState<number>(defaultBearing);
  const [pitch, setPitch] = React.useState<number>(defaultPitch);
  const [fullscreen, setFullscreen] = React.useState<boolean>(false);
  const [center, setCenter] = React.useState<Coordinate>(defaultCenter);
  const [projection, setProjection] =
    React.useState<Projection>(defaultProjection);

  const values: MapContext = {
    mapRef,
    containerRef,
    projection,
    setProjection,
    loaded,
    setLoaded,
    center,
    setCenter,
    zoom,
    setZoom,
    bearing,
    setBearing,
    pitch,
    setPitch,
    fullscreen,
    setFullscreen,
  };

  return <Context.Provider value={values}>{children}</Context.Provider>;
}

type MapProps = {
  children?: React.ReactNode;
  className?: string;
  theme?: Theme;
  styles?: Record<Theme, Style>;
  onViewportChange?: (viewport: {
    center: Coordinate;
    zoom: number;
    bearing: number;
    pitch: number;
  }) => void;
} & Omit<MapOptions, "container" | "style">;

function Map({
  children,
  className,
  theme: themeProp,
  styles,
  onViewportChange,
  ...props
}: MapProps) {
  const {
    mapRef,
    containerRef,
    loaded,
    setLoaded,
    projection,
    center: initialCenter,
    setCenter,
    zoom: initialZoom,
    setZoom,
    bearing: initialBearing,
    setBearing,
    pitch: initialPitch,
    setPitch,
  } = React.useContext(Context);

  const resolvedTheme = useTheme(themeProp);
  const [isStyleLoaded, setIsStyleLoaded] = React.useState(false);

  const mapStyles = React.useMemo(
    () => ({
      dark: styles?.dark ?? defaultStyles.dark,
      light: styles?.light ?? defaultStyles.light,
    }),
    [styles],
  );

  const currentStyleRef = React.useRef<Style | null>(null);
  const styleTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(
    null,
  );
  const onViewportChangeRef = React.useRef(onViewportChange);
  const [initialMapConfig] = React.useState<{
    style: Style;
    center: Coordinate;
    zoom: number;
    bearing: number;
    pitch: number;
    projection: Projection;
    options: Omit<MapOptions, "container" | "style">;
  }>(() => {
    const initialStyle =
      resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light;
    return {
      style: initialStyle,
      center: initialCenter,
      zoom: initialZoom,
      bearing: initialBearing,
      pitch: initialPitch,
      projection,
      options: props,
    };
  });

  const clearStyleTimeout = React.useCallback(() => {
    if (styleTimeoutRef.current) {
      clearTimeout(styleTimeoutRef.current);
      styleTimeoutRef.current = null;
    }
  }, []);

  React.useEffect(() => {
    onViewportChangeRef.current = onViewportChange;
  }, [onViewportChange]);

  React.useEffect(() => {
    if (!containerRef.current) return;
    currentStyleRef.current = initialMapConfig.style;

    const map = new MapLibreMap({
      container: containerRef.current,
      style: initialMapConfig.style,
      center: [
        initialMapConfig.center.longitude,
        initialMapConfig.center.latitude,
      ],
      zoom: initialMapConfig.zoom,
      bearing: initialMapConfig.bearing,
      pitch: initialMapConfig.pitch,
      renderWorldCopies: true,
      attributionControl: false,
      ...initialMapConfig.options,
    });

    mapRef.current = map;

    const handleStyleData = () => {
      clearStyleTimeout();
      styleTimeoutRef.current = setTimeout(() => {
        setIsStyleLoaded(true);
        if (initialMapConfig.projection)
          map.setProjection({ type: initialMapConfig.projection });
      }, 100);
    };

    const handleLoad = () => setLoaded(true);

    const handleMove = () => {
      const c = map.getCenter();
      const viewport = {
        center: { latitude: c.lat, longitude: c.lng } as Coordinate,
        zoom: map.getZoom(),
        bearing: map.getBearing(),
        pitch: map.getPitch(),
      };
      setCenter(viewport.center);
      setZoom(viewport.zoom);
      setBearing(viewport.bearing);
      setPitch(viewport.pitch);
      onViewportChangeRef.current?.(viewport);
    };

    map.on("load", handleLoad);
    map.on("styledata", handleStyleData);
    map.on("move", handleMove);

    return () => {
      clearStyleTimeout();
      map.off("load", handleLoad);
      map.off("styledata", handleStyleData);
      map.off("move", handleMove);
      map.remove();
      mapRef.current = null;
      setLoaded(false);
      setIsStyleLoaded(false);
    };
  }, [
    containerRef,
    mapRef,
    setBearing,
    setCenter,
    setLoaded,
    setPitch,
    setZoom,
    clearStyleTimeout,
    initialMapConfig,
  ]);

  React.useEffect(() => {
    const map = mapRef.current;
    if (!map) return;

    const newStyle =
      resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light;
    if (currentStyleRef.current === newStyle) return;

    clearStyleTimeout();
    currentStyleRef.current = newStyle;
    setIsStyleLoaded(false);
    map.setStyle(newStyle, { diff: true });
  }, [resolvedTheme, mapStyles, clearStyleTimeout, mapRef]);

  const isReady = loaded && isStyleLoaded;

  return (
    <div
      ref={containerRef}
      className={cn("relative h-full w-full overflow-hidden", className)}
    >
      {(!isReady || !loaded) && <Loading />}
      {loaded && children}
    </div>
  );
}

function MapCopyright({ title }: { title?: string }) {
  return (
    <span className="absolute bottom-1 right-2 z-20 text-card-foreground/75 bg-card/50 rounded-full px-2 border border-border select-none">
      {title && `${title} | `}© CARTO, © OpenStreetMap contributors
    </span>
  );
}

type MapControlsProps = {
  align?: "top-left" | "top-right" | "bottom-left" | "bottom-right";
  className?: string;
  children: React.ReactNode;
};

const controlPositionClasses: Record<
  NonNullable<MapControlsProps["align"]>,
  string
> = {
  "top-left": "top-2 left-2",
  "top-right": "top-2 right-2",
  "bottom-left": "bottom-10 left-2",
  "bottom-right": "bottom-10 right-2",
};

function MapControls({
  align = "bottom-right",
  className,
  children,
}: MapControlsProps) {
  return (
    <div
      className={cn(
        "absolute z-10 flex flex-col gap-2",
        controlPositionClasses[align],
        className,
      )}
    >
      {children}
    </div>
  );
}

function MapControlGroup({
  children,
  className,
}: {
  children: React.ReactNode;
  className?: string;
}) {
  return (
    <ButtonGroup
      orientation={"vertical"}
      className={cn("bg-card rounded-lg min-h-9 flex", className)}
    >
      {children}
    </ButtonGroup>
  );
}

function MapControlZoomIn({ className }: { className?: string }) {
  const { mapRef } = React.useContext(Context);

  const handleZoomIn = () => {
    mapRef.current?.zoomTo((mapRef.current.getZoom() ?? 0) + 1, {
      duration: 300,
    });
  };

  return (
    <Button
      onClick={handleZoomIn}
      className={cn("min-h-8 grow max-w-9", className)}
      size="sm"
      variant={"outline"}
    >
      <Plus />
    </Button>
  );
}

function MapControlZoomOut({ className }: { className?: string }) {
  const { mapRef } = React.useContext(Context);

  const handleZoomOut = () => {
    mapRef.current?.zoomTo((mapRef.current.getZoom() ?? 0) - 1, {
      duration: 300,
    });
  };

  return (
    <Button
      onClick={handleZoomOut}
      className={cn("min-h-8 grow max-w-9", className)}
      size="sm"
      variant={"outline"}
    >
      <Minus />
    </Button>
  );
}

function MapControlPitchUp({ className }: { className?: string }) {
  const { mapRef, pitch, setPitch } = React.useContext(Context);

  const handlePitchUp = () => {
    const currentPitch = mapRef.current?.getPitch() ?? pitch ?? 0;
    const newPitch = Math.min(currentPitch + 15, 85);
    mapRef.current?.flyTo({
      pitch: newPitch,
      duration: 500,
    });
    setPitch(newPitch);
  };

  return (
    <Button
      onClick={handlePitchUp}
      className={cn("min-h-8 grow max-w-9", className)}
      size="sm"
      variant={"outline"}
    >
      <ChevronUp />
    </Button>
  );
}

function MapControlPitchDown({ className }: { className?: string }) {
  const { mapRef, pitch, setPitch } = React.useContext(Context);

  const handlePitchDown = () => {
    const currentPitch = mapRef.current?.getPitch() ?? pitch ?? 0;
    const newPitch = Math.max(currentPitch - 15, 0);
    mapRef.current?.flyTo({
      pitch: newPitch,
      duration: 500,
    });
    setPitch(newPitch);
  };

  return (
    <Button
      onClick={handlePitchDown}
      className={cn("min-h-8 grow max-w-9", className)}
      size="sm"
      variant={"outline"}
    >
      <ChevronDown />
    </Button>
  );
}

function MapControlRotate({ className }: { className?: string }) {
  const { mapRef } = React.useContext(Context);
  const compassRef = React.useRef<SVGSVGElement>(null);
  const currentRef = React.useRef<number>(0);
  const previousRef = React.useRef<number>(0);

  React.useEffect(() => {
    const map = mapRef.current;
    if (!map || !compassRef.current) return;

    const compass = compassRef.current;
    const update = () => {
      const b = map.getBearing();
      const p = map.getPitch();

      let delta = b - previousRef.current;
      if (delta > 180) delta -= 360;
      if (delta < -180) delta += 360;

      currentRef.current += delta;
      previousRef.current = b;

      compass.style.transform = `rotateX(${p}deg) rotateZ(${-currentRef.current}deg)`;
    };

    map.on("rotate", update);
    map.on("pitch", update);
    update();

    return () => {
      map.off("rotate", update);
      map.off("pitch", update);
    };
  }, [mapRef]);

  const handleReset = () => {
    mapRef.current?.resetNorthPitch({ duration: 300 });
  };

  return (
    <Button
      onClick={handleReset}
      className={cn("min-h-8 grow max-w-9", className)}
      size="sm"
      variant={"outline"}
    >
      <svg
        ref={compassRef}
        viewBox="0 0 24 24"
        className="size-5 transition-transform duration-500"
        style={{ transformStyle: "preserve-3d" }}
        aria-hidden
      >
        <path d="M12 2L16 12H12V2Z" className="fill-primary" />
        <path d="M12 2L8 12H12V2Z" className="fill-primary/60" />
        <path d="M12 22L16 12H12V22Z" className="fill-muted-foreground/60" />
        <path d="M12 22L8 12H12V22Z" className="fill-muted-foreground/30" />
      </svg>
    </Button>
  );
}

function MapControlFullscreen({ className }: { className?: string }) {
  const { mapRef, fullscreen, setFullscreen } = React.useContext(Context);

  const handleFullscreen = () => {
    const container = mapRef.current?.getContainer();
    if (!container) return;
    if (document.fullscreenElement) {
      document.exitFullscreen();
      setFullscreen(false);
    } else {
      container.requestFullscreen();
      setFullscreen(true);
    }
  };

  React.useEffect(() => {
    const handler = () => {
      if (!document.fullscreenElement) setFullscreen(false);
    };
    document.addEventListener("fullscreenchange", handler);
    return () => document.removeEventListener("fullscreenchange", handler);
  }, [setFullscreen]);

  return (
    <Button
      onClick={handleFullscreen}
      className={cn("min-h-8 grow max-w-9", className)}
      size="sm"
      variant={"outline"}
    >
      {fullscreen ? <Minimize /> : <Maximize />}
    </Button>
  );
}

function MapControlProjection({ className }: { className?: string }) {
  const { mapRef, projection, setProjection } = React.useContext(Context);

  const handleProjection = () => {
    const currentProjection: Projection =
      (mapRef.current?.getProjection().type as Projection) ??
      projection ??
      "mercator";
    const newProjection =
      currentProjection === "mercator" ? "globe" : "mercator";
    mapRef.current?.setProjection({
      type: newProjection,
    });
    setProjection(newProjection);
  };

  return (
    <Button
      onClick={handleProjection}
      className={cn("min-h-8 grow max-w-9", className)}
      size="sm"
      variant={"outline"}
    >
      {projection === "mercator" ? <MapIcon /> : <Globe />}
    </Button>
  );
}

type MapControlLocateProps = {
  className?: string;
  onLocate?: (coordinates: Coordinate) => void;
};

function MapControlLocate({ className, onLocate }: MapControlLocateProps) {
  const { mapRef } = React.useContext(Context);
  const [loading, setLoading] = React.useState(false);

  const handleLocate = () => {
    if (!("geolocation" in navigator)) return;
    setLoading(true);
    navigator.geolocation.getCurrentPosition(
      (pos) => {
        const coordinates = {
          longitude: pos.coords.longitude,
          latitude: pos.coords.latitude,
        };
        mapRef.current?.flyTo({
          center: [coordinates.longitude, coordinates.latitude],
          zoom: 14,
          duration: 1500,
        });
        onLocate?.(coordinates);
        setLoading(false);
      },
      (err) => {
        console.error("Geolocation error:", err);
        setLoading(false);
      },
    );
  };

  return (
    <Button
      onClick={handleLocate}
      disabled={loading}
      className={cn("min-h-8 grow max-w-9", className)}
      size="sm"
      variant={"outline"}
    >
      {loading ? <Loader2 className="animate-spin" /> : <Locate />}
    </Button>
  );
}

function MapMarkers({
  children,
  className,
}: {
  children: React.ReactNode;
  className?: string;
}) {
  return <div className={cn("contents", className)}>{children}</div>;
}

type MarkerContextValue = { marker: Marker };
const MarkerContext = React.createContext<MarkerContextValue | null>(null);

type MapMarkerProps = {
  coordinates: Coordinate;
  children: React.ReactNode;
  onClick?: (e: MouseEvent) => void;
  onMouseEnter?: (e: MouseEvent) => void;
  onMouseLeave?: (e: MouseEvent) => void;
  onDragStart?: (lngLat: { lng: number; lat: number }) => void;
  onDrag?: (lngLat: { lng: number; lat: number }) => void;
  onDragEnd?: (lngLat: { lng: number; lat: number }) => void;
} & Omit<MarkerOptions, "element">;

function MapMarker({
  coordinates,
  children,
  onClick,
  onMouseEnter,
  onMouseLeave,
  onDragStart,
  onDrag,
  onDragEnd,
  draggable = false,
  ...markerOptions
}: MapMarkerProps) {
  const { mapRef, loaded } = React.useContext(Context);
  const [initialMarkerConfig] = React.useState(() => ({
    markerOptions,
    draggable,
    coordinates,
  }));

  const callbacksRef = React.useRef({
    onClick,
    onMouseEnter,
    onMouseLeave,
    onDragStart,
    onDrag,
    onDragEnd,
  });
  const [marker, setMarker] = React.useState<Marker | null>(null);

  React.useEffect(() => {
    callbacksRef.current = {
      onClick,
      onMouseEnter,
      onMouseLeave,
      onDragStart,
      onDrag,
      onDragEnd,
    };
  }, [onClick, onMouseEnter, onMouseLeave, onDragStart, onDrag, onDragEnd]);

  React.useEffect(() => {
    const el = document.createElement("div");
    const instance = new Marker({
      ...initialMarkerConfig.markerOptions,
      element: el,
      draggable: initialMarkerConfig.draggable,
    }).setLngLat([
      initialMarkerConfig.coordinates.longitude,
      initialMarkerConfig.coordinates.latitude,
    ]);

    const handleClick = (e: MouseEvent) => callbacksRef.current.onClick?.(e);
    const handleMouseEnter = (e: MouseEvent) =>
      callbacksRef.current.onMouseEnter?.(e);
    const handleMouseLeave = (e: MouseEvent) =>
      callbacksRef.current.onMouseLeave?.(e);
    const handleDragStart = () => {
      const ll = instance.getLngLat();
      callbacksRef.current.onDragStart?.({ lng: ll.lng, lat: ll.lat });
    };
    const handleDrag = () => {
      const ll = instance.getLngLat();
      callbacksRef.current.onDrag?.({ lng: ll.lng, lat: ll.lat });
    };
    const handleDragEnd = () => {
      const ll = instance.getLngLat();
      callbacksRef.current.onDragEnd?.({ lng: ll.lng, lat: ll.lat });
    };

    el.addEventListener("click", handleClick);
    el.addEventListener("mouseenter", handleMouseEnter);
    el.addEventListener("mouseleave", handleMouseLeave);
    instance.on("dragstart", handleDragStart);
    instance.on("drag", handleDrag);
    instance.on("dragend", handleDragEnd);

    setMarker(instance);

    return () => {
      el.removeEventListener("click", handleClick);
      el.removeEventListener("mouseenter", handleMouseEnter);
      el.removeEventListener("mouseleave", handleMouseLeave);
      instance.off("dragstart", handleDragStart);
      instance.off("drag", handleDrag);
      instance.off("dragend", handleDragEnd);
      instance.remove();
      setMarker(null);
    };
  }, [initialMarkerConfig]);

  React.useEffect(() => {
    const map = mapRef.current;
    if (!map || !loaded || !marker) return;
    marker.addTo(map);
    return () => {
      marker.remove();
    };
  }, [mapRef, loaded, marker]);

  React.useEffect(() => {
    if (!marker) return;

    const lngLat = marker.getLngLat();
    if (
      lngLat.lng !== coordinates.longitude ||
      lngLat.lat !== coordinates.latitude
    ) {
      marker.setLngLat([coordinates.longitude, coordinates.latitude]);
    }

    if (marker.isDraggable() !== draggable) marker.setDraggable(draggable);

    const currentOffset = marker.getOffset();
    const rawOffset = markerOptions.offset ?? [0, 0];
    const [ox, oy] = Array.isArray(rawOffset)
      ? rawOffset
      : [rawOffset.x, rawOffset.y];
    if (currentOffset.x !== ox || currentOffset.y !== oy)
      marker.setOffset(rawOffset);

    if (marker.getRotation() !== (markerOptions.rotation ?? 0))
      marker.setRotation(markerOptions.rotation ?? 0);
  }, [
    marker,
    coordinates.longitude,
    coordinates.latitude,
    draggable,
    markerOptions.offset,
    markerOptions.rotation,
  ]);

  if (!marker) return null;

  return (
    <MarkerContext.Provider value={{ marker }}>
      {children}
    </MarkerContext.Provider>
  );
}

type MarkerIconProps = {
  children?: React.ReactNode;
  className?: string;
};

function MapMarkerIcon({ children, className }: MarkerIconProps) {
  const context = React.useContext(MarkerContext);
  if (!context?.marker)
    throw new Error("MapMarkerIcon must be used in a MapMarker Component");
  return createPortal(
    <div className={cn("relative cursor-pointer", className)}>
      {children ?? (
        <div className="flex items-center justify-center relative size-6 rounded-full bg-primary">
          <MapPin className="size-5 text-primary-foreground stroke-2.5" />
        </div>
      )}
    </div>,
    context.marker.getElement(),
  );
}

type MarkerPopupProps = {
  children: React.ReactNode;
  className?: string;
  closeButton?: boolean;
} & Omit<PopupOptions, "className" | "closeButton">;

function MapMarkerPopup({ children, ...popupOptions }: MarkerPopupProps) {
  const context = React.useContext(MarkerContext);
  if (!context?.marker)
    throw new Error("MapMarkerPopup must be used in a MapMarker Component");
  const { mapRef, loaded } = React.useContext(Context);
  const [container] = React.useState(() => document.createElement("div"));
  const [popup] = React.useState(() =>
    new Popup({
      offset: 16,
      ...popupOptions,
      closeButton: false,
    })
      .setMaxWidth("none")
      .setDOMContent(container),
  );

  React.useEffect(() => {
    if (!mapRef.current || !loaded) return;
    const content = container.parentElement;
    if (content && content.classList.contains("maplibregl-popup-content")) {
      content.style.setProperty("background", "transparent");
      content.style.setProperty("padding", "0");
      content.style.setProperty("box-shadow", "none");

      const tip = content.parentElement?.querySelector(
        ".maplibregl-popup-tip",
      ) as HTMLElement;
      if (tip) tip.style.display = "none";
    }

    popup.setDOMContent(container);
    context.marker.setPopup(popup);

    return () => {
      context.marker.setPopup(null);
    };
  }, [container, context.marker, loaded, mapRef, popup]);

  React.useEffect(() => {
    popup.setOffset(popupOptions.offset ?? 16);
    if (popupOptions.maxWidth) {
      popup.setMaxWidth(popupOptions.maxWidth);
    }
  }, [container, popup, popupOptions.offset, popupOptions.maxWidth]);

  return createPortal(children, container);
}

export {
  Map,
  MapControlFullscreen,
  MapControlGroup,
  MapControlLocate,
  MapControlPitchDown,
  MapControlPitchUp,
  MapControlProjection,
  MapControlRotate,
  MapControls,
  MapControlZoomIn,
  MapControlZoomOut,
  MapCopyright,
  MapMarker,
  MapMarkerIcon,
  MapMarkerPopup,
  MapMarkers,
  MapProvider,
  type Coordinate,
  type Projection,
  type Style,
};

More from nordaun

Browse all components in this library →