@adamscybot/react-leaflet-component-marker
Version:
A tiny wrapper for react-leaflet's <Marker /> component that allows you to use a React component as a marker, with working state, handlers, and access to parent contexts.
142 lines (139 loc) • 6.53 kB
JavaScript
import React, { useState, useId, useMemo, useCallback, isValidElement, useLayoutEffect, forwardRef, useEffect, } from 'react';
import { isValidElementType } from 'react-is';
import { createPortal } from 'react-dom';
import { Marker as ReactLeafletMarker, } from 'react-leaflet';
import { divIcon, DomEvent, } from 'leaflet';
import { createHtmlPortalNode, OutPortal, InPortal } from 'react-reverse-portal';
import { useCoordsFromPointExpression } from './lib/useCoordsFromPointExpression.js';
import { logCodedString } from './lib/logging.js';
const DEFAULT_ICON_SIZE = [0, 0];
const ComponentMarker = forwardRef(({ eventHandlers: providedEventHandlers, icon: providedIcon, componentIconOpts: { layoutMode = 'fit-content', disableClickPropagation, disableScrollPropagation, rootDivOpts, rootSizeWarning, } = {}, ...otherProps }, ref) => {
const [markerRendered, setMarkerRendered] = useState(false);
const [, setChangeCount] = useState(0);
const id = 'marker-' + useId();
const portalNode = React.useMemo(() => createHtmlPortalNode({
attributes: {
'data-react-component-marker': 'portal-parent',
style: 'width:100%;height:100%;',
},
}), []);
useEffect(() => {
if (rootSizeWarning !== false &&
layoutMode === 'fit-parent' &&
rootDivOpts?.iconSize === undefined) {
console.warn(logCodedString('UNBOUND_FIT_PARENT', `The 'componentIconOpts.rootDivOpts.iconSize' option was not set but 'componentIconOpts.layoutMode' was set to 'fit-parent'. This means your React component will not be properly bound by the parent.
To disable this warning set 'componentIconOpts.rootSizeWarning' to false.`));
}
}, [layoutMode, rootSizeWarning, rootDivOpts?.iconSize]);
const { attribution, className, iconAnchor, iconSize = DEFAULT_ICON_SIZE, pane, popupAnchor, tooltipAnchor, } = rootDivOpts ?? {};
const iconDeps = [
id,
layoutMode,
attribution,
className,
pane,
Boolean(disableClickPropagation),
Boolean(disableScrollPropagation),
...useCoordsFromPointExpression(iconSize),
...useCoordsFromPointExpression(iconAnchor),
...useCoordsFromPointExpression(popupAnchor),
...useCoordsFromPointExpression(tooltipAnchor),
];
const icon = useMemo(() => {
const parentStyles = layoutMode === 'fit-content'
? 'width: min-content; transform: translate(-50%, -50%)'
: 'width: 100%; height: 100%';
return divIcon({
html: `<div data-react-component-marker="root" style="${parentStyles}" id="${id}"></div>`,
...(iconSize ? { iconSize } : []),
...(iconAnchor ? { iconAnchor } : []),
...(popupAnchor ? { popupAnchor } : []),
...(tooltipAnchor ? { tooltipAnchor } : []),
pane,
attribution,
className,
});
}, iconDeps);
useLayoutEffect(() => {
setChangeCount((prev) => prev + 1);
}, [icon]);
const handleAddEvent = useCallback((...args) => {
setMarkerRendered(true);
if (providedEventHandlers?.add)
providedEventHandlers.add(...args);
}, [providedEventHandlers?.add]);
const handleRemoveEvent = useCallback((...args) => {
setMarkerRendered(false);
if (providedEventHandlers?.remove)
providedEventHandlers.remove(...args);
}, [providedEventHandlers?.remove]);
const eventHandlers = useMemo(() => ({
...providedEventHandlers,
add: handleAddEvent,
remove: handleRemoveEvent,
}), [providedEventHandlers, handleAddEvent, handleRemoveEvent]);
let portalTarget = null;
if (markerRendered) {
portalTarget = document.getElementById(id);
}
useEffect(() => {
if (!portalTarget)
return;
if (disableClickPropagation) {
DomEvent.disableClickPropagation(portalTarget);
}
if (disableScrollPropagation) {
DomEvent.disableScrollPropagation(portalTarget);
}
}, [portalTarget, disableClickPropagation, disableScrollPropagation]);
return (React.createElement(React.Fragment, null,
React.createElement(ReactLeafletMarker, { ref: ref, ...otherProps, eventHandlers: eventHandlers, icon: icon }),
markerRendered && portalTarget !== null && (React.createElement(React.Fragment, null,
React.createElement(InPortal, { node: portalNode }, providedIcon),
createPortal(React.createElement(OutPortal, { node: portalNode }), portalTarget)))));
});
/**
* A modified version of the [react-leaflet Marker](https://react-leaflet.js.org/docs/api-components/#marker) component that is extended such that it allows a {@link ReactElement} to be used as the icon.
*
* @example
* Basic usage:
* ```
* // Define marker component
* const MarkerIconExample = () => {
* return (
* <>
* <button onClick={() => console.log("button 1 clicked")}>Button 1</button>
* <button onClick={() => console.log("button 2 clicked")}>Button 2</button>
* </>
* )
* }
*
* // Use marker component
* <Marker position={[51.505, -0.091]} icon={<MarkerIconExample />} />
* ```
**/
export const Marker = forwardRef(({ icon: Icon, componentIconOpts, ...otherProps }, ref) => {
const validElement = isValidElement(Icon);
const validComponent = isValidElementType(Icon);
useEffect(() => {
if (!validElement &&
!validComponent &&
componentIconOpts !== undefined &&
componentIconOpts.unusedOptsWarning !== false) {
console.warn(logCodedString('UNUSED_OPTIONS', `The 'componentIconOpts' prop was set but the 'icon' prop was not set to a React component or element. These options will be unused.
To disable this warning set 'componentIconOpts.unusedOptsWarning' to false.`));
}
}, [
componentIconOpts,
componentIconOpts?.unusedOptsWarning,
validElement,
validComponent,
]);
if (validElement) {
return (React.createElement(ComponentMarker, { ref: ref, icon: Icon, componentIconOpts: componentIconOpts, ...otherProps }));
}
if (validComponent) {
return (React.createElement(ComponentMarker, { ref: ref, icon: React.createElement(Icon, null), componentIconOpts: componentIconOpts, ...otherProps }));
}
return (React.createElement(ReactLeafletMarker, { ref: ref, icon: Icon, ...otherProps }));
});