Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/api-reference/components/map-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,16 @@ on the map. This is useful for styling or targeting the control from outside,
since the children are rendered into a container that is managed by the Maps
JavaScript API rather than directly into the React tree.

#### `style`: CSSProperties

Inline styles applied to the same container element. Useful when the control has
to break out of the default layout, for example `{inset: '0px 0px auto 0px'}` to
make it span the full width of the map.

Note that the container is positioned by the Maps JavaScript API, which lays out
all controls registered for the same `position`. Styles that change the size or
placement of the container can therefore affect how neighbouring controls are
arranged.

[gmp-custom-ctrl]: https://developers.google.com/maps/documentation/javascript/controls#CustomControls
[gmp-ctrl-pos]: https://developers.google.com/maps/documentation/javascript/controls#ControlPositioning
112 changes: 112 additions & 0 deletions src/components/__tests__/map-control.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {cleanup, render} from '@testing-library/react';
import {ControlPosition, MapControl} from '../map-control';
import {useMap} from '../../hooks/use-map';

import type {CSSProperties} from 'react';

jest.mock('../../hooks/use-map');

let useMapMock: jest.MockedFn<typeof useMap>;
Expand Down Expand Up @@ -89,3 +91,113 @@ test('className prop updates are reflected on the control container', () => {
expect(controlEl).not.toHaveClass('updated-class');
expect(controlEl).not.toHaveClass('initial-class');
});

test('style prop is applied to the control container', () => {
render(
<MapControl
position={ControlPosition.BOTTOM_CENTER}
style={{width: '100%', zIndex: 5}}>
<button>control button</button>
</MapControl>
);

const controlsArray = mapInstance.controls[ControlPosition.BOTTOM_CENTER];
const [controlEl] = (controlsArray.push as jest.Mock).mock.calls[0];

expect(controlEl.style.width).toBe('100%');
expect(controlEl.style.zIndex).toBe('5');
});

test('style prop updates are reflected on the control container', () => {
const {rerender} = render(
<MapControl
position={ControlPosition.BOTTOM_CENTER}
style={{width: '100%', zIndex: 5}}>
<button>control button</button>
</MapControl>
);

const controlsArray = mapInstance.controls[ControlPosition.BOTTOM_CENTER];
const [controlEl] = (controlsArray.push as jest.Mock).mock.calls[0];

expect(controlEl.style.width).toBe('100%');
expect(controlEl.style.zIndex).toBe('5');

// properties dropped between renders have to be removed from the element,
// not just overwritten
rerender(
<MapControl position={ControlPosition.BOTTOM_CENTER} style={{width: '50%'}}>
<button>control button</button>
</MapControl>
);

expect(controlEl.style.width).toBe('50%');
expect(controlEl.style.zIndex).toBe('');

rerender(
<MapControl position={ControlPosition.BOTTOM_CENTER}>
<button>control button</button>
</MapControl>
);

expect(controlEl.style.width).toBe('');
expect(controlEl.style.zIndex).toBe('');
});

test('style prop applies react style semantics for numbers', () => {
render(
<MapControl
position={ControlPosition.BOTTOM_CENTER}
style={{width: 100, zIndex: 5}}>
<button>control button</button>
</MapControl>
);

const controlsArray = mapInstance.controls[ControlPosition.BOTTOM_CENTER];
const [controlEl] = (controlsArray.push as jest.Mock).mock.calls[0];

// numbers get an implicit px suffix, except for unitless properties
expect(controlEl.style.width).toBe('100px');
expect(controlEl.style.zIndex).toBe('5');
});

test('style prop supports css custom properties', () => {
render(
<MapControl
position={ControlPosition.BOTTOM_CENTER}
style={{'--control-bg': 'red'} as CSSProperties}>
<button>control button</button>
</MapControl>
);

const controlsArray = mapInstance.controls[ControlPosition.BOTTOM_CENTER];
const [controlEl] = (controlsArray.push as jest.Mock).mock.calls[0];

expect(controlEl.style.getPropertyValue('--control-bg')).toBe('red');
});

test('style prop updates leave unrelated inline styles intact', () => {
const {rerender} = render(
<MapControl
position={ControlPosition.BOTTOM_CENTER}
style={{width: '100%'}}>
<button>control button</button>
</MapControl>
);

const controlsArray = mapInstance.controls[ControlPosition.BOTTOM_CENTER];
const [controlEl] = (controlsArray.push as jest.Mock).mock.calls[0];

// the maps api positions the control container once it has been pushed, so
// updating the style prop must not discard properties it did not set
controlEl.style.position = 'absolute';

rerender(
<MapControl position={ControlPosition.BOTTOM_CENTER} style={{width: '50%'}}>
<button>control button</button>
</MapControl>
);

expect(controlEl.style.width).toBe('50%');
expect(controlEl.style.position).toBe('absolute');
});
30 changes: 24 additions & 6 deletions src/components/map-control.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import {FunctionComponent, useEffect, useMemo} from 'react';
import {
FunctionComponent,
useEffect,
useLayoutEffect,
useMemo,
useRef
} from 'react';
import {createPortal} from 'react-dom';
import {useMap} from '../hooks/use-map';
import {setValueForStyles} from '../libraries/set-value-for-styles';

import type {PropsWithChildren} from 'react';
import type {CSSProperties, PropsWithChildren} from 'react';

type MapControlProps = PropsWithChildren<{
position: ControlPosition;
className?: string;
style?: CSSProperties;
}>;

/**
Expand Down Expand Up @@ -50,15 +58,25 @@ export type ControlPosition =
export const MapControl: FunctionComponent<MapControlProps> = ({
children,
position,
className
className,
style
}) => {
const controlContainer = useMemo(() => document.createElement('div'), []);
const map = useMap();

useEffect(() => {
// eslint-disable-next-line react-hooks/immutability -- the control container DOM node is intentionally mutated from effects
// ---- update className and styles for `controlContainer`
// prevStyleRef stores previously applied style properties, so they can be
// removed when unset
const prevStyleRef = useRef<CSSProperties | null>(null);
// eslint-disable-next-line react-hooks/immutability -- the control container DOM node is intentionally mutated from effects
useLayoutEffect(() => {
setValueForStyles(controlContainer, style || null, prevStyleRef.current);

prevStyleRef.current = style || null;

// eslint-disable-next-line react-hooks/immutability -- see above
controlContainer.className = className ?? '';
}, [controlContainer, className]);
}, [controlContainer, className, style]);

useEffect(() => {
if (!map) return;
Expand Down