From add0fd8429ff9ec6a0761db78ff70b831fb00ae6 Mon Sep 17 00:00:00 2001 From: Samitha Widanage Date: Mon, 10 Aug 2026 18:19:16 +0800 Subject: [PATCH] feat(map-control): add style prop Allows inline styles to be applied to the control container, so a control can break out of the default layout, for example spanning the full width of the map with `inset: 0px 0px auto 0px`. The styles are compared by value, since callers typically pass an inline object literal that would otherwise be a new reference on every render. Properties dropped between renders are removed from the element rather than left behind. Refs #379 --- docs/api-reference/components/map-control.md | 11 ++ src/components/__tests__/map-control.test.tsx | 112 ++++++++++++++++++ src/components/map-control.tsx | 30 ++++- 3 files changed, 147 insertions(+), 6 deletions(-) diff --git a/docs/api-reference/components/map-control.md b/docs/api-reference/components/map-control.md index 3ca1b6e1..d422949c 100644 --- a/docs/api-reference/components/map-control.md +++ b/docs/api-reference/components/map-control.md @@ -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 diff --git a/src/components/__tests__/map-control.test.tsx b/src/components/__tests__/map-control.test.tsx index 7e89c691..93f1b7c8 100644 --- a/src/components/__tests__/map-control.test.tsx +++ b/src/components/__tests__/map-control.test.tsx @@ -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; @@ -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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + expect(controlEl.style.width).toBe('50%'); + expect(controlEl.style.zIndex).toBe(''); + + rerender( + + + + ); + + expect(controlEl.style.width).toBe(''); + expect(controlEl.style.zIndex).toBe(''); +}); + +test('style prop applies react style semantics for numbers', () => { + render( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + expect(controlEl.style.width).toBe('50%'); + expect(controlEl.style.position).toBe('absolute'); +}); diff --git a/src/components/map-control.tsx b/src/components/map-control.tsx index d5915136..10e03088 100644 --- a/src/components/map-control.tsx +++ b/src/components/map-control.tsx @@ -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; }>; /** @@ -50,15 +58,25 @@ export type ControlPosition = export const MapControl: FunctionComponent = ({ 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(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;