Skip to content

shadcn-map refactor — refined tasks

Source idea (verbatim from the request):

for a nice consistent styling, analyze the following shadcn map component. https://shadcn-map.vercel.app/docs lay out an implementation plan to refactor our current implementation to leverage the shadcn map component.

What shadcn-map actually is

shadcn-map (MIT, tonghohin) is a single-file shadcn registry component — registry/new-york-v4/ui/map.tsx — that wraps react-leaflet in shadcn/ui-styled primitives. It is installed with:

pnpm dlx shadcn@latest add @shadcn-map/map

Facts verified by reading registry.json and the registry source, not assumed:

  • It is a thin wrapper, not a replacement engine. Map renders MapContainer with attributionControl={false}, zoomControl={false}, zoom = 15, maxZoom = 18 and className="z-50 size-full min-h-96 flex-1 rounded-md", then spreads ...props. Everything else from MapContainerProps passes through, so crs={L.CRS.Simple}, zoomSnap, minZoom etc. keep working exactly as today.
  • It solves our client-only problem internally. createLazyComponent wraps every react-leaflet import in lazy() + an isMounted gate, and useLeaflet() dynamically imports leaflet, leaflet-draw and leaflet.fullscreen in an effect. It exports useLeaflet for consumers.
  • It exports MapTileLayer, but that is not our tile layer. It is a light/dark Carto basemap switcher driven by next-themes, plus registration into the MapLayers context. Our GridTileLayer URL scheme ({path}/{z-minZoom}/map_{x}_{y}.webp with negative y) cannot be expressed as a {z}/{x}/{y} template, and image uploads are an L.ImageOverlay.
  • MapMarker builds a fresh L.divIcon from renderToString(icon) on every render. This is precisely the churn the comment in app/components/map.tsx documents (setIcon rebinds popups and resets marker DOM).
  • Registry deps do not match our style. registry.json lists button, button-group, dropdown-menu and the remote place-autocomplete.json. Ours are base-rhea / Base UI (app/components/ui/dropdown-menu.tsx imports @base-ui/react/menu); the registry source is Radix-flavoured (DropdownMenuContent container={map.getContainer()}).
  • registry.json ships a css block of @layer base Leaflet overrides (.leaflet-container, .leaflet-popup-*, .leaflet-tooltip, .leaflet-div-icon, .leaflet-control-attribution, .marker-cluster div, …) that the CLI appends to app/app.css.
  • New runtime deps: leaflet-draw, leaflet.markercluster, leaflet.fullscreen, react-leaflet-markercluster, next-themes; dev: @types/leaflet-draw, @types/leaflet.markercluster.

Decisions taken

Question Decision
Install method Run the CLI, then adapt the generated file to Base UI
Scope Full component — core, zoom, fullscreen, layers, draw, cluster, locate, search
GridTileLayer / upload overlay Keep as our own child components inside <Map>
POI pins Move to MapMarker, wrapped in React.memo

Known frictions to resolve during implementation

  1. Base UI vs Radix dropdown. MapLayersControl uses <DropdownMenuContent align="end" container={map.getContainer()}>. Our DropdownMenuContent is a Base UI Menu.Portal + Positioner + Popup; the portal target prop is on Menu.Portal. DropdownMenuRadioGroup, DropdownMenuRadioItem and DropdownMenuCheckboxItem exist in our file but their prop names must be checked one-for-one.
  2. next-themes in MapTileLayer. The app is dark-only — app/root.tsx hardcodes <body className="dark">. Rather than install next-themes for a Carto basemap we will never render, drop the useTheme() call and the darkUrl / darkAttribution props from MapTileLayer.
  3. place-autocomplete for MapSearchControl. The remote registry dep is also Radix-based and geocodes real-world addresses. It will be installed and adapted, but MapSearchControl stays unmounted in our routes until there is a fictional-map place index to search.
  4. react-leaflet version. We pin 5.0.0-rc.2; shadcn-map targets stable 5.x. Bump and re-verify before touching component code.
  5. Map's default className. z-50 ... rounded-md min-h-96 will fight our full-bleed h-full w-full container and layer over the sidebars. Override via className at the call site.
  6. Button sizes. The registry uses size="icon-sm" / variant="secondary"; both exist in app/components/ui/button.tsx, but our --radius is 0.25rem with rounded-2xl buttons, so control clusters will look different from the docs screenshots. Expect a styling pass.

Tasks

1. Dependencies and install

  • Bump react-leaflet to stable 5.x; run pnpm typecheck, pnpm build to confirm the current map still works before any refactor.
  • pnpm dlx shadcn@latest add @shadcn-map/map, answering no to overwriting button, button-group, dropdown-menu.
  • Verify the added deps landed in package.json: leaflet-draw, leaflet.markercluster, leaflet.fullscreen, react-leaflet-markercluster (+ the two @types packages). Remove next-themes if the CLI added it.
  • Review the @layer base block the CLI appended to app/app.css; keep it, but check .leaflet-container { @apply !bg-card !font-[inherit] } against our Faculty Glyphic / Playfair theme.

2. Adapt app/components/ui/map.tsx

  • Rewrite MapLayersControl's dropdown usage against our Base UI DropdownMenu* API (portal container, radio group, checkbox items).
  • Strip useTheme / next-themes from MapTileLayer; keep name, url, attribution and the MapLayers registration.
  • Adapt MapSearchControl to whatever place-autocomplete the CLI wrote, or stub it out behind the same export name if the Radix version cannot be reconciled cheaply.
  • Replace @/registry/new-york-v4/ui/* and @/lib/utils imports with our ~/components/ui/* and ~/lib/utils aliases (the CLI should do this via components.json — verify).
  • pnpm typecheck must pass with the file untouched by our app code yet.

3. Port app/components/map.tsx

The file keeps its role as the client-only, lazily imported map module. It stops owning container/marker styling and starts composing shadcn-map primitives.

  • Swap MapContainer → Map, passing crs, center, zoom, minZoom, maxZoom, zoomSnap, zoomDelta, wheelPxPerZoomLevel through, and className="h-full w-full rounded-none" to neutralise the defaults.
  • Keep GridTileLayer, TileSetLayer and UploadImageLayer unchanged as children — including the [map, tileSet.id] dependency comment, which still applies.
  • Replace buildPoiIcon / usePoiIcons / renderToStaticMarkup with a PoiMarkerIcon React component (coloured circle + lucide icon from POI_TYPE_ICON_COMPONENTS) rendered through MapMarker's icon prop. Delete the now-dead L.divIcon helpers.
  • Wrap PoiMarker in React.memo keyed on poi.id, poi.position, poi.type and the resolved type definition, so loader revalidations do not rebuild every divIcon. Verify the marker DOM is not recreated when toggling view/edit mode.
  • Replace the Tooltip in PendingPoiMarker with MapTooltip (permanent, side="top").
  • Keep MapClickHandler (useMapEvent("click", …)) as-is.
  • Add <MapZoomControl /> and <MapFullscreenControl /> inside <Map>; pick positions that clear the edit sidebars.
  • Collapse the duplicated upload / tileSet branches into one <Map> with a switched source layer, now that container props are the only difference.

4. Route wiring

  • In app/routes/project-map.tsx, keep the lazy() + mounted gate — our module still imports leaflet at the top level for GridTileLayer. Note in a comment that shadcn-map's own lazy-loading does not remove this requirement.
  • Re-verify the POI flows end to end: place, add, select, edit, delete, and the "click empty map deselects" behaviour.

5. Groundwork for the backlog (no behaviour change yet)

These are wired but left inert; they exist so the next features do not need another refactor.

  • Wrap the map body in <MapLayers> and register the tile/overlay source as a named MapTileLayer + MapLayerGroup, then render <MapLayersControl /> — this is the hook for Map Layers in docs/backlog.md.
  • Confirm MapDrawControl + MapDrawPolyline / MapDrawPolygon mount correctly under CRS.Simple (leaflet-draw does distance/area math that assumes geographic CRS — expect wrong readouts; we render with showLength: false / showArea: false already as defaults). This is the hook for routes and regions/borders.
  • Confirm MapMarkerClusterGroup works with CRS.Simple before committing to it for the avesmaps dataset (docs/v0.0.3/avesmaps-dataset.md).
  • Do not mount MapLocateControl or MapSearchControl in the map route; geolocation is meaningless on fictional maps.

Verification

Per .github/copilot-instructions.md:

pnpm typecheck
pnpm format
pnpm build

Manual checks, since there is no test suite:

  • Grid tile map (avesmaps) pans/zooms and stays inside maxBounds.
  • Uploaded-image map fits bounds on load.
  • POI popups/sidebar do not close on unrelated re-renders.
  • Zoom and fullscreen controls do not pan the map when clicked (MapControlContainer handles propagation — confirm it does under our CSS).