Skip to content

Commit ab6f864

Browse files
committed
v3 wip
1 parent 3a42474 commit ab6f864

24 files changed

+2163
-373
lines changed

.prettierrc

+5-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
{
2-
"semi": false,
3-
"singleQuote": true
4-
}
2+
"semi": false,
3+
"singleQuote": true,
4+
"plugins": ["@homer0/prettier-plugin-jsdoc"],
5+
"jsdocAllowDescriptionOnNewLinesForTags": ["remarks"]
6+
}

README.md

+8-5
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,17 @@
66
</p>
77
<p align="center">
88
📍 Use a React component as <a href="https://react-leaflet.js.org/">React Leaflet</a> markers.<br/>
9-
🔄 Familiar swap-in API that feels like React Leaflet.<br/>
9+
🔄 Swap-in API that feels like React Leaflet (yup, popups & tooltips too).<br/>
1010
✨ Can use state, context etc. It's a full component. No BS.<br/>
11-
💪 It's strongly typed.
11+
🖼️ Powerful layout options with sensible automated defaults. Put your icon where you want.
12+
💪 It's a strongly typed ESM package, automatically tested against a real browser.
1213
</p>
1314

1415
# What is it
1516

16-
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**.
17+
A wrapper for [react-leaflet](https://react-leaflet.js.org/)'s `<Marker />` component that allows you to use a React component as a marker icon, with **working state, handlers, and access to parent contexts**. It also works with existing [react-leaflet](https://react-leaflet.js.org/) functionality that supplements markers, i.e. `<Popup>` and `<Tooltip>`.
18+
19+
It handles markers of any size (dynamically) and positioning is handled for you. If you like, you can also tune this with a sensible API that hides away Leaflet's cumbersome absolute-pixel-offsets approach. Want your icon anchored below the coordinates rather than above? No problem.
1720

1821
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.
1922

@@ -96,7 +99,7 @@ Below is a list of properties this object can be provided.
9699
The `layoutMode` controls how the bounding box of the React component marker behaves. It accepts two options:
97100

98101
- `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.
99-
- `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.
102+
- `fit-parent`. In this mode, the dimensions of the React component marker are bound by the `iconSize` passed to `componentIconOpts.manualLayoutOpts`. 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.
100103

101104
#### `rootDivOpts`
102105

@@ -129,4 +132,4 @@ Can be set to `false` in order to not warn in console about cases where `compone
129132

130133
`true` by default.
131134

132-
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`.
135+
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 `manualLayoutOpts`.

cypress/specs/marker.cy.tsx

+230-70
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,22 @@ import React, {
55
type PropsWithChildren,
66
type HTMLAttributes,
77
} from 'react'
8-
import { Marker } from '../../src/Marker'
9-
import { MapContainer, type MarkerProps, Popup, TileLayer } from 'react-leaflet'
8+
import {
9+
MapContainer,
10+
Popup,
11+
TileLayer,
12+
Tooltip,
13+
Marker as OriginalMarker,
14+
type TooltipProps,
15+
} from 'react-leaflet'
16+
import { FaMapPin } from 'react-icons/fa'
1017
import { useLeafletContext } from '@react-leaflet/core'
1118
import { divIcon, type LatLngExpression } from 'leaflet'
1219
import 'leaflet/dist/leaflet.css'
1320

21+
import { Marker } from '../../src/Marker'
22+
import { type MarkerProps } from '../../src/lib/types'
23+
1424
const BUTTON_TEXT = 'react-leaflet-component-marker button'
1525
const ORIGINAL_MARKER_TEXT = 'I am an original marker'
1626
const CLICK_COUNT_TEST_ID = 'click-count'
@@ -19,7 +29,7 @@ interface MarkerIconExampleProps extends HTMLAttributes<HTMLDivElement> {
1929
onButtonClick?: MouseEventHandler
2030
}
2131

22-
const MarkerIconExample = ({
32+
const MarkerIconInteractiveExample = ({
2333
onButtonClick,
2434
...divAttrs
2535
}: MarkerIconExampleProps) => {
@@ -51,6 +61,10 @@ const MarkerIconExample = ({
5161
)
5262
}
5363

64+
const MarkerIconSimple = () => {
65+
return <FaMapPin style={{ width: 150, height: 240, color: 'red' }} />
66+
}
67+
5468
const CENTER: LatLngExpression = [51.505, -0.091]
5569
const ZOOM = 13
5670
const LeafletWrapper = ({ children }: PropsWithChildren<object>) => (
@@ -84,90 +98,236 @@ const MarkerTest = ({ onButtonClick, eventHandlers }: MarkerTestProps) => {
8498
{renderMarker && (
8599
<Marker
86100
position={CENTER}
87-
icon={<MarkerIconExample onButtonClick={handleButtonClick} />}
101+
icon={
102+
<MarkerIconInteractiveExample onButtonClick={handleButtonClick} />
103+
}
88104
eventHandlers={eventHandlers}
89-
/>
105+
>
106+
<div>
107+
<Popup>Test</Popup>
108+
<Tooltip>Test</Tooltip>
109+
</div>
110+
</Marker>
90111
)}
91112
</LeafletWrapper>
92113
)
93114
}
94115

95116
describe('<Marker />', () => {
96-
it('Mounts & unmounts interactive component as Leaflet marker', () => {
97-
const onButtonClickSpy = cy.spy().as('onButtonClickSpy')
98-
cy.mount(<MarkerTest onButtonClick={onButtonClickSpy} />)
99-
100-
cy.get("[data-context-available='true']").should('exist')
101-
cy.contains(BUTTON_TEXT).should('exist').click()
102-
cy.get('@onButtonClickSpy').should('have.been.calledOnce')
103-
cy.contains(BUTTON_TEXT).should('not.exist')
104-
})
117+
describe('Baseline swap-in behaviors', () => {
118+
it('Allows other non-component icon types', () => {
119+
cy.mount(
120+
<LeafletWrapper>
121+
<Marker
122+
position={CENTER}
123+
icon={divIcon({ html: `<div>${ORIGINAL_MARKER_TEXT}</div>` })}
124+
/>
125+
</LeafletWrapper>,
126+
)
105127

106-
it('Calls user-supplied add event handler', () => {
107-
const onAddSpy = cy.spy().as('onAddSpy')
108-
cy.mount(
109-
<MarkerTest
110-
eventHandlers={{
111-
add() {
112-
onAddSpy()
113-
},
114-
}}
115-
/>,
116-
)
117-
118-
cy.get('@onAddSpy').should('have.been.calledOnce')
119-
})
128+
cy.contains(ORIGINAL_MARKER_TEXT).should('exist')
129+
})
120130

121-
it('Allows other non-component icon types', () => {
122-
cy.mount(
123-
<LeafletWrapper>
124-
<Marker
125-
position={CENTER}
126-
icon={divIcon({ html: `<div>${ORIGINAL_MARKER_TEXT}</div>` })}
127-
/>
128-
</LeafletWrapper>,
129-
)
131+
it('Calls user-supplied add event handler', () => {
132+
const onAddSpy = cy.spy().as('onAddSpy')
133+
cy.mount(
134+
<MarkerTest
135+
eventHandlers={{
136+
add() {
137+
onAddSpy()
138+
},
139+
}}
140+
/>,
141+
)
130142

131-
cy.contains(ORIGINAL_MARKER_TEXT).should('exist')
143+
cy.get('@onAddSpy').should('have.been.calledOnce')
144+
})
132145
})
133146

134-
it('Allows mounting component reference', () => {
135-
cy.mount(
136-
<LeafletWrapper>
137-
<Marker position={CENTER} icon={MarkerIconExample} />
138-
</LeafletWrapper>,
139-
)
140-
cy.get("[data-context-available='true']").should('exist')
141-
cy.contains(BUTTON_TEXT).should('exist').click()
147+
describe('Component icon support', () => {
148+
describe('Basic', () => {
149+
it('Mounts & unmounts interactive component as Leaflet marker', () => {
150+
const onButtonClickSpy = cy.spy().as('onButtonClickSpy')
151+
cy.mount(<MarkerTest onButtonClick={onButtonClickSpy} />)
152+
153+
cy.get("[data-context-available='true']").should('exist')
154+
cy.contains(BUTTON_TEXT).should('exist').click()
155+
cy.get('@onButtonClickSpy').should('have.been.calledOnce')
156+
cy.contains(BUTTON_TEXT).should('not.exist')
157+
})
158+
159+
it('Allows mounting component reference', () => {
160+
cy.mount(
161+
<LeafletWrapper>
162+
<Marker position={CENTER} icon={MarkerIconInteractiveExample} />
163+
</LeafletWrapper>,
164+
)
165+
cy.get("[data-context-available='true']").should('exist')
166+
cy.contains(BUTTON_TEXT).should('exist').click()
167+
})
168+
169+
it.only('Anchors to the same point as the default marker', () => {
170+
cy.mount(
171+
<LeafletWrapper>
172+
<Marker position={CENTER} icon={MarkerIconSimple} />
173+
<OriginalMarker position={CENTER} />
174+
</LeafletWrapper>,
175+
)
176+
177+
cy.get('img.leaflet-marker-icon').then((originalMarker) => {
178+
cy.get('[data-react-component-marker="root"]').then((newMarker) => {
179+
expect(newMarker.offset().top + newMarker.height()).equal(
180+
originalMarker.offset().top + originalMarker.height(),
181+
'Component marker and original marker have same position',
182+
)
183+
})
184+
})
185+
})
186+
187+
it('Maintains component instance when `rootDivOpts` changes', () => {
188+
const DynamicDivOptsExample = () => {
189+
const [iconSize, setIconSize] = useState<[number, number]>([200, 200])
190+
return (
191+
<Marker
192+
position={CENTER}
193+
icon={
194+
<MarkerIconInteractiveExample
195+
onButtonClick={() =>
196+
setIconSize(([w, h]) => [w + 100, h + 100])
197+
}
198+
style={{ width: iconSize[0], height: iconSize[1] }}
199+
/>
200+
}
201+
// componentIconOpts={{
202+
// rootDivOpts: { iconSize },
203+
// layoutMode: 'fit-parent',
204+
// }}
205+
></Marker>
206+
)
207+
}
208+
209+
cy.mount(
210+
<LeafletWrapper>
211+
<DynamicDivOptsExample />
212+
</LeafletWrapper>,
213+
)
214+
cy.get(`[data-testid='${CLICK_COUNT_TEST_ID}']`).should(
215+
'contain.text',
216+
'0',
217+
)
218+
cy.contains(BUTTON_TEXT).click()
219+
cy.get(`[data-testid='${CLICK_COUNT_TEST_ID}']`).should(
220+
'contain.text',
221+
'1',
222+
)
223+
})
224+
})
142225
})
143226

144-
it('Maintains component instance when `rootDivOpts` changes', () => {
145-
const DynamicDivOptsExample = () => {
146-
const [iconSize, setIconSize] = useState<[number, number]>([200, 200])
227+
describe('Tooltip support', () => {
228+
const TOOLTIP_TEXT = 'I am an example tooltip'
229+
230+
const TooltipMarker = ({
231+
tooltipProps,
232+
...otherProps
233+
}: {
234+
tooltipProps?: TooltipProps
235+
} & Omit<MarkerProps, 'position' | 'icon'>) => {
147236
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-
/>
237+
<LeafletWrapper>
238+
<Marker position={CENTER} icon={MarkerIconSimple} {...otherProps}>
239+
<Tooltip {...tooltipProps}>{TOOLTIP_TEXT}</Tooltip>
240+
</Marker>
241+
</LeafletWrapper>
161242
)
162243
}
163244

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')
245+
it('Renders on hover', () => {
246+
cy.mount(<TooltipMarker tooltipProps={{ direction: 'left' }} />)
247+
cy.get('[data-react-component-marker="root"]').trigger('mouseover')
248+
cy.contains(TOOLTIP_TEXT).should('exist')
249+
})
250+
251+
it('Auto positions tooltip centred to the side', () => {
252+
cy.mount(<TooltipMarker tooltipProps={{ permanent: true }} />)
253+
254+
cy.get('[data-react-component-marker="root"]').then((newMarker) => {
255+
cy.wait(500).then(() => {
256+
cy.get('.leaflet-tooltip').then((tooltip) => {
257+
const markerMidYDelta =
258+
newMarker.offset().top + newMarker.height() / 2
259+
const markerLeftXDelta = newMarker.offset().left
260+
const tooltipMidYDelta = tooltip.offset().top + tooltip.height() / 2
261+
const tooltipRightXDelta = tooltip.offset().left + tooltip.width()
262+
263+
console.log(markerMidYDelta, tooltipMidYDelta)
264+
expect(tooltipMidYDelta).approximately(
265+
markerMidYDelta,
266+
7,
267+
'Tooltip centred on y axis with marker',
268+
)
269+
270+
expect(tooltipRightXDelta).approximately(
271+
markerLeftXDelta,
272+
25,
273+
'Tooltip aligned with marker edge on x axis',
274+
)
275+
})
276+
})
277+
})
278+
})
279+
})
280+
281+
describe('Popup support', () => {
282+
const POPUP_TEXT = 'I am an example popup'
283+
284+
beforeEach(() => {
285+
cy.mount(
286+
<LeafletWrapper>
287+
<Marker
288+
position={CENTER}
289+
icon={MarkerIconSimple}
290+
componentIconOpts={{
291+
autoLayoutOpts: { icon: { locationAnchor: 'top' } },
292+
}}
293+
>
294+
<Popup>{POPUP_TEXT}</Popup>
295+
</Marker>
296+
</LeafletWrapper>,
297+
)
298+
})
299+
300+
it.only('Renders on click', () => {
301+
cy.get('[data-react-component-marker="root"]').click()
302+
cy.contains(POPUP_TEXT).should('exist')
303+
})
304+
305+
it('Auto positions popup above & centred', () => {
306+
cy.get('[data-react-component-marker="root"]').click()
307+
308+
cy.get('[data-react-component-marker="root"]').then((newMarker) => {
309+
cy.wait(500).then(() => {
310+
cy.get('.leaflet-popup').then((popup) => {
311+
const markerTopYDelta = newMarker.offset().top
312+
const markerMidXDelta =
313+
newMarker.offset().left + newMarker.width() / 2
314+
const popupBottomYDelta = popup.offset().top + popup.height()
315+
const popupMidXDelta = popup.offset().left + popup.width() / 2
316+
317+
expect(popupBottomYDelta).approximately(
318+
markerTopYDelta,
319+
7,
320+
'Popup just above top of marker',
321+
)
322+
323+
expect(popupMidXDelta).approximately(
324+
markerMidXDelta,
325+
2,
326+
'Popup centred on x axis with marker',
327+
)
328+
})
329+
})
330+
})
331+
})
172332
})
173333
})

0 commit comments

Comments
 (0)