Skip to content

Commit 47ccffc

Browse files
committed
feat: Added new warnings for common misconfigurations, and options to turn them off.
feat: Added new props to control propagation of pan/click events.. feat: Improved component API to properly delimit and contain options from this library from other props. Added typings to catch common misconfig. fix: Removed throwing error when target portal DOM element can not be found, to fix common SSR scenarios. fix: Corrected vertical positioning in `fit-content` mode. fix: Changes to the `divIcon` opts (now via `rootDivOpts`) no longer cause component to lose state. BREAKING CHANGE: Structure of `componentIconOpts` has changed signifcantly. See docs.
1 parent 561ff08 commit 47ccffc

8 files changed

+479
-180
lines changed

README.md

+58-18
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,24 @@
1-
# react-leaflet-component-marker
1+
<p align="center">
2+
<h1 align="center">react-leaflet-component-marker</h1>
3+
</p>
4+
<p align="center">
5+
📍 Use a React component as <a href="https://react-leaflet.js.org/">React Leaflet</a> markers.<br/>
6+
🔄 Familiar swap-in API that feels like React Leaflet.<br/>
7+
✨ Can use state, context etc. It's a full component. No BS.<br/>
8+
💪 It's strongly typed.
29

3-
A tiny wrapper for [react-leaflet](https://react-leaflet.js.org/)'s `<Marker />` component that allows you to use a React component as a marker, with working state, handlers, and access to parent contexts.
10+
</p>
11+
<br/>
412

5-
The approach this library uses differs from other approaches that use `renderToString` in that it instead uses React's [Portal](https://react.dev/reference/react-dom/createPortal) functionality to achieve the effect. That means the component is not static, but a full first-class component that can have its own state, event handlers & lifecycle.
13+
# What is it
614

7-
This library is typed via TypeScript.
15+
A tiny wrapper for [react-leaflet](https://react-leaflet.js.org/)'s `<Marker />` component that allows you to use a React component as a marker, with **working state, handlers, and access to parent contexts**.
816

9-
# Docs
17+
The approach this library uses differs from other approaches that use `renderToString` in that it instead uses React's [Portal](https://react.dev/reference/react-dom/createPortal) functionality to achieve the effect. That means the component is not static, but a full first-class component that can have its own state, event handlers & lifecycle.
1018

11-
## Installation
19+
I struggled to find something that worked in this way I could drop something in from a design system in there and have all the context available such that it works, and all the interactions working as they should.
20+
21+
# Installation
1222

1323
Install using your projects package manager.
1424

@@ -30,17 +40,17 @@ yarn install --save @adamscybot/react-leaflet-component-marker
3040
pnpm add @adamscybot/react-leaflet-component-marker
3141
```
3242

33-
## Usage
43+
# Docs
3444

35-
### Using a React Component as a marker
45+
## Simple Usage
3646

37-
Instead of importing `Marker` from `react-leaflet`, instead import `Marker` from `react-leaflet-component-marker`.
47+
Instead of importing `Marker` from `react-leaflet`, instead import `Marker` from `@adamscybot/react-leaflet-component-marker`.
3848

3949
The `icon` prop is extended to allow for a JSX element of your choosing. All other props are identical to the `react-leaflet` [Marker](https://react-leaflet.js.org/docs/api-components/#marker) API.
4050

4151
The `icon` prop can also accept all of the original types of icons that the underlying `react-leaflet` Marker accepts. Though there is no gain in using this library for this case, it may help if you want to just use this library in place of Marker universally.
4252

43-
#### Basic Example
53+
### Example
4454

4555
```javascript
4656
import React from 'react'
@@ -72,18 +82,48 @@ const App = () => {
7282
}
7383
```
7484

75-
### Advanced Sizing and Positioning
85+
## Advanced Usage
86+
87+
The `componentIconOpts` prop can be passed, which is an object with additional options for more advanced use cases. Note, in the case where you are not passing a component to `icon`, these settings will be ignored.
88+
89+
Below is a list of properties this object can be provided.
90+
91+
### `layoutMode`
92+
93+
The `layoutMode` controls how the bounding box of the React component marker behaves. It accepts two options:
94+
95+
- `fit-content` _(default)_. In this mode, the React component itself defines the dimensions of the marker. The component can shrink and expand at will. Logic internally to this library centers the component on its coordinates to match Leaflets default positioning; however, Leaflet itself is effectively no longer in control of this.
96+
- `fit-parent`. In this mode, the dimensions of the React component marker are bound by the `iconSize` passed to `componentIconOpts.rootDivOpts`. Leaflet is therefore in control of the dimensions and positioning. Component markers should use elements with 100% width & height to fill the available size if needed.
97+
98+
## `rootDivOpts`
99+
100+
> [!NOTE]
101+
> Some options are not supported since they do not apply or make sense in the case of a React component marker. The unsupported options are `html`, `bgPos`, `shadowUrl`, `shadowSize`, `shadowAnchor`, `shadowRetinaUrl`, `iconUrl` and `iconRetinaUrl`.
102+
103+
An object containing properties from the supported subset of the underlying Leaflet [`divIcon`](https://leafletjs.com/reference.html#divicon) options, which this library uses as a containing wrapper.
104+
105+
If using `fit-parent`, you must set `iconSize` here.
106+
107+
## `disableScrollPropagation`
108+
109+
`false` by default.
110+
111+
If set to `true`, panning/scrolling the map will not be possible "through" the component marker.
112+
113+
## `disableClickPropagation`
114+
115+
`false` by default.
76116

77-
Note, that it is often possible to achieve the desired effect by use of margins/padding on the React icon component itself. However, in some cases, adjustments may be needed to get pixel perfect like popup positioning
117+
If set to `true`, clicking on the component marker will not be captured by the underlying map.
78118

79-
`iconComponentOpts` can be passed which provides a subset of the [options](https://leafletjs.com/reference.html#icon) that can be passed to an underlying leaflet icon, which is used by this library as an intermediary wrapper. It should be considered an escape hatch.
119+
## `unusedOptsWarning`
80120

81-
`iconComponentLayout` can be passed to control the alignment and size of the React component. It defaults to `fit-content`, meaning the React Component decides its own size and is not constrained by `iconSize` (which defaults to `[0,0]`). The library automatically handles the alignment of the component such that it is centred horizontally with the marker coordinates, regardless of the component's size (which can even change dynamically). Note the anchor options that can be passed to `iconComponentOpts` remain functional with `fit-content`.
121+
`true` by default.
82122

83-
If more granular control is needed, `iconComponentLayout` can be set `fit-parent` which defers all sizing and positioning to leaflets configuration options, that can be provided via the aforementioned `iconComponentOpts`. This means you will likely need to pass an `iconSize` to `iconComponentOpts`. In this mode, the React icon component should also have a root element that has a width and height of 100%, and it should prevent overflowing. The downside to this approach is the component size is inherently static. The upside is Leaflet knows about the icon size, and so the default anchor coordinates for other elements like popups, will be likely closer to the default expectations.
123+
Can be set to `false` in order to not warn in console about cases where `componentIconOpts` was set but `icon` was not a React component.
84124

85-
### Gotchas
125+
## `unusedOptsWarning`
86126

87-
Currently, if any options in `iconComponentOpts` have a material change (new `iconSize` or changed anchors), the React Component will completely remount and lose any state it had. This will be fixed in a future release.
127+
`true` by default.
88128

89-
Hot reloading causes markers to disappear. This will be fixed in a future release.
129+
Can be set to `false` in order to not warn in console about cases where the `layoutMode` was `fit-parent` but their was no `iconSize` defined in the `rootDivOpts`.

cypress/specs/marker.cy.tsx

+61-5
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,52 @@
11
import React, {
2+
useCallback,
23
useState,
34
type MouseEventHandler,
45
type PropsWithChildren,
6+
type HTMLAttributes,
57
} from 'react'
68
import { Marker } from '../../src/Marker'
7-
import { MapContainer, type MarkerProps, TileLayer } from 'react-leaflet'
9+
import { MapContainer, type MarkerProps, Popup, TileLayer } from 'react-leaflet'
810
import { useLeafletContext } from '@react-leaflet/core'
911
import { divIcon, type LatLngExpression } from 'leaflet'
1012
import 'leaflet/dist/leaflet.css'
1113

1214
const BUTTON_TEXT = 'react-leaflet-component-marker button'
1315
const ORIGINAL_MARKER_TEXT = 'I am an original marker'
16+
const CLICK_COUNT_TEST_ID = 'click-count'
1417

15-
interface MarkerIconExampleProps {
18+
interface MarkerIconExampleProps extends HTMLAttributes<HTMLDivElement> {
1619
onButtonClick?: MouseEventHandler
1720
}
1821

19-
const MarkerIconExample = ({ onButtonClick }: MarkerIconExampleProps) => {
22+
const MarkerIconExample = ({
23+
onButtonClick,
24+
...divAttrs
25+
}: MarkerIconExampleProps) => {
26+
const [clickCount, setClickCount] = useState(0)
2027
const context = useLeafletContext()
28+
29+
const handleButtonClick = useCallback<MouseEventHandler<HTMLButtonElement>>(
30+
(e) => {
31+
setClickCount((prev) => prev + 1)
32+
onButtonClick?.(e)
33+
},
34+
[onButtonClick],
35+
)
36+
37+
const { style, ...otherDivAttrs } = divAttrs ?? {}
38+
2139
return (
22-
<div data-context-available={context?.map ? 'true' : 'false'}>
23-
<button onClick={onButtonClick}>{BUTTON_TEXT}</button>
40+
<div
41+
data-context-available={context?.map ? 'true' : 'false'}
42+
style={{ padding: 10, background: 'lightblue', ...style }}
43+
{...otherDivAttrs}
44+
>
45+
<button onClick={handleButtonClick}>{BUTTON_TEXT}</button>
46+
<div>
47+
Click count:
48+
<span data-testid={CLICK_COUNT_TEST_ID}>{clickCount}</span>
49+
</div>
2450
</div>
2551
)
2652
}
@@ -114,4 +140,34 @@ describe('<Marker />', () => {
114140
cy.get("[data-context-available='true']").should('exist')
115141
cy.contains(BUTTON_TEXT).should('exist').click()
116142
})
143+
144+
it('Maintains component instance when `rootDivOpts` changes', () => {
145+
const DynamicDivOptsExample = () => {
146+
const [iconSize, setIconSize] = useState<[number, number]>([200, 200])
147+
return (
148+
<Marker
149+
position={CENTER}
150+
icon={
151+
<MarkerIconExample
152+
onButtonClick={() => setIconSize(([w, h]) => [w + 100, h + 100])}
153+
style={{ width: '100%', height: '100%' }}
154+
/>
155+
}
156+
componentIconOpts={{
157+
rootDivOpts: { iconSize },
158+
layoutMode: 'fit-parent',
159+
}}
160+
/>
161+
)
162+
}
163+
164+
cy.mount(
165+
<LeafletWrapper>
166+
<DynamicDivOptsExample />
167+
</LeafletWrapper>,
168+
)
169+
cy.get(`[data-testid='${CLICK_COUNT_TEST_ID}']`).should('contain.text', '0')
170+
cy.contains(BUTTON_TEXT).click()
171+
cy.get(`[data-testid='${CLICK_COUNT_TEST_ID}']`).should('contain.text', '1')
172+
})
117173
})

package.json

+5-3
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@
2626
"scripts": {
2727
"lint": "eslint './src/**/*.ts' './src/**/*.tsx'",
2828
"build": "tsc --project tsconfig.build.json",
29-
"test": "cypress run-ct --browser chrome",
30-
"test:dev": "cypress open-ct --browser chrome"
29+
"test": "cypress run --component --browser chrome",
30+
"test:dev": "cypress open --component --browser chrome"
3131
},
3232
"devDependencies": {
3333
"@react-leaflet/core": "^2.1.0",
@@ -58,7 +58,9 @@
5858
"react-leaflet": "^4.0.0"
5959
},
6060
"dependencies": {
61-
"react-is": "^18.0.0"
61+
"react-is": "^18.0.0",
62+
"react-reverse-portal": "^2.1.2",
63+
"type-fest": "^4.10.3"
6264
},
6365
"release": {
6466
"branches": [

pnpm-lock.yaml

+21-11
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)