From 92854c9103c90a8f0ad1c16eba729402b1e36919 Mon Sep 17 00:00:00 2001 From: Martin Schuhfuss Date: Fri, 3 May 2024 10:09:36 +0200 Subject: [PATCH] feat(infowindow): InfoWindow overhaul (#335) A thorough overhaul of the InfoWindow implementation, tests and documentation. fix(infowindow): removed unneeded dependency in infowindow hooks fix(infowindow): add missing cleanup for infowindow fix(infowindow): better dependency checks, using `useDeepCompareEffect` where needed feat(infowindow): add missing options and events feat(infowindow): add `className` and `style` props --- docs/api-reference/components/info-window.md | 265 ++++++++++++------ src/components/__tests__/info-window.test.tsx | 148 ++++++++-- src/components/info-window.tsx | 166 +++++++---- src/hooks/use-maps-event-listener.ts | 19 ++ src/libraries/set-value-for-styles.ts | 167 +++++++++++ 5 files changed, 588 insertions(+), 177 deletions(-) create mode 100644 src/hooks/use-maps-event-listener.ts create mode 100644 src/libraries/set-value-for-styles.ts diff --git a/docs/api-reference/components/info-window.md b/docs/api-reference/components/info-window.md index 02414abd..d502c26c 100644 --- a/docs/api-reference/components/info-window.md +++ b/docs/api-reference/components/info-window.md @@ -1,127 +1,206 @@ # `` Component -React component to display an [Info Window](https://developers.google.com/maps/documentation/javascript/reference/info-window) instance. +[InfoWindows][gmp-infowindow] are small, temporary overlays on the map that +are typically used to display additional bits of information for locations on +the map – for example, to add a label or image to a marker. They can be +freely positioned on the map, or they can be "anchored" to a marker. -## Usage +When an `InfoWindow` is shown, the map will make sure to reposition the viewport +such that the InfoWindow is well visible within the map container. -Info Windows can either be displayed alone, or in connection with a Marker -that will be used as an anchor. The content of the InfoWindow can either be -text or any JSX and is specified as the children of the InfoWindow -component. +InfoWindows always use the same well-known styling and are limited in how much +their look and feel can be customized. Any JSX element added to the +InfoWindow component as children will get rendered into the content of the +InfoWindow. -Under the hood this is using the `google.maps.InfoWindow` class. The default -InfoWindow does come with a close-button that can't easily be removed or -controlled via the API. This means that the visibility of the infowindow -cannot be fully controlled by your application. To keep your state in sync -with the map, you can listen for the `onCloseClick` event. +:::note -### Single Info Window implementation +The rendered InfoWindow includes a close-button that can't be removed or +controlled via the Google Maps API. This means that the application can't +fully control the visibility of the InfoWindow. -```tsx -import React from 'react'; -import {APIProvider, Map, InfoWindow} from '@vis.gl/react-google-maps'; - -const App = () => ( - - - - Hello World! - - - -); +To keep your state in sync with the map, you have to provide a listener for the +`onClose` event so the application knows when then InfoWindow was closed by +the map or the user. -export default App; -``` +::: -### Marker with JSX implementation +:::tip -```tsx -import React from 'react'; -import { - APIProvider, - Map, - Marker, - useMarkerRef -} from '@vis.gl/react-google-maps'; +If you need more control over an InfoWindow than can be offered by the +`InfoWindow` component, you can use the [`AdvancedMarker`](./advanced-marker.md) +component with html-content to create a custom implementation. + +::: + +## Usage + +### Minimal Example -const App = () => { - const [markerRef, marker] = useMarkerRef(); +In this example, the InfoWindow will be initially shown when the map is +rendered, but the user can close it and there wouldn't be a way to get it back. +```tsx +const MapWithInfoWindow = () => { return ( - - - - - -

Hello everyone!

-

This is an Info Window

- -
-
-
+ + + The content of the info window is here. + + ); }; - -export default App; ``` -**Note**: The position prop of the InfoWindow will be ignored when an anchor is specified. +### Infowindow Attached to Marker -### Advanced Marker View implementation +A more typical use-case is to have an InfoWindow shown on click for a marker. +One way to implement this is to write a custom component +`MarkerWithInfoWindow` that can then be added to any `Map`. ```tsx -import React, {FunctionComponent} from 'react'; -import { - APIProvider, - Map, - AdvancedMarker, - useAdvancedMarkerRef -} from '@vis.gl/react-google-maps'; - -const App = () => { +const MarkerWithInfoWindow = ({position}) => { + // `markerRef` and `marker` are needed to establish the connection between + // the marker and infowindow (if you're using the Marker component, you + // can use the `useMarkerRef` hook instead). const [markerRef, marker] = useAdvancedMarkerRef(); - const [infowindowShown, setInfowindowShown] = useState(false); - const toggleInfoWindow = () => - setInfowindowShown(previousState => !previousState); + const [infoWindowShown, setInfoWindowShown] = useState(false); - const closeInfoWindow = () => setInfowindowShown(false); + // clicking the marker will toggle the infowindow + const handleMarkerClick = useCallback(() => + setInfoWindowShown(isShown => !isShown) + ); + + // if the maps api closes the infowindow, we have to synchronize our state + const handleClose = useCallback(() => setInfoWindowShown(false)); return ( - - '}> - - - {infowindowShown && ( - - You can drag and drop me. - - )} - - + <> + + + {infoWindowShown && ( + +

InfoWindow content!

+

Some arbitrary html to be rendered into the InfoWindow.

+
+ )} + ); }; - -export default App; ``` ## Props -The InfoWindowProps interface extends the [google.maps.InfoWindowOptions interface](https://developers.google.com/maps/documentation/javascript/reference/info-window#InfoWindowOptions) and includes all possible options available for a Google Maps Info Window. +The InfoWindowProps interface roughly extends the [`google.maps.InfoWindowOptions` +interface][gmp-infowindow-options] and includes all the options available for a +InfoWindow as props. All supported options are listed below. -- `onCloseClick` adds the event listener 'closeclick' to the info infowindow -- `anchor` a Marker or AdvancedMarker instance to be used as an anchor +### Required -```tsx -export type InfoWindowProps = google.maps.InfoWindowOptions & { - onCloseClick?: () => void; - anchor?: google.maps.Marker | google.maps.marker.AdvancedMarkerElement | null; -}; -``` +There are no strictly required props for the InfoWindow component, but it is +required to set either a `position` or an `anchor` to show the infowindow. + +### General Props + +#### `position`: google.maps.LatLngLiteral + +The LatLng at which to display this InfoWindow. + +:::note + +When an `anchor` is specified, the `position` prop will be ignored. + +::: + +#### `anchor`: google.maps.Marker | google.maps.marker.AdvancedMarkerElement + +A Marker or AdvancedMarker instance to be used as an anchor. If specified, the +InfoWindow will be positioned at the top-center of the anchor. + +#### `zIndex`: number + +All InfoWindows are displayed on the map in order of their zIndex, with +higher values displaying in front of InfoWindows with lower values. By +default, InfoWindows are displayed according to their latitude, with +InfoWindows of lower latitudes appearing in front of InfoWindows at higher +latitudes. InfoWindows are always displayed in front of markers. + +#### `pixelOffset`: [number, number] + +The offset, in pixels, from the tip of the info window to the point on the +map at whose geographical coordinates the info window is anchored. +If an InfoWindow is opened with an anchor, the `pixelOffset` will be +calculated from the anchor's top/center. + +#### `disableAutoPan`: boolean + +Disable panning the map to make the InfoWindow fully visible when it opens. + +#### `shouldFocus`: boolean + +Whether focus should be moved inside the InfoWindow when it is opened. When +this property isn't set, a heuristic is used to decide whether focus should +be moved. + +It is recommended to explicitly set this property to fit your needs as the +heuristic is subject to change and may not work well for all use cases. + +### Content Props + +#### `className`: string + +A className to be assigned to the topmost element in the infowindow content. + +#### `style`: [CSSProperties][react-dev-styling] + +A style declaration to be added to the topmost element in the infowindow +content. This works exactly as the style property for any other +html element. + +#### `ariaLabel`: string + +AriaLabel to assign to the InfoWindow. + +#### `minWidth`: number + +Minimum width of the InfoWindow, regardless of the content's width. When +using this property, it is strongly recommended to set the minWidth to a +value less than the width of the map (in pixels). + +:::note + +The `minWidth` can't be changed while the InfoWindow is open. + +::: + +#### `maxWidth`: number + +Maximum width of the InfoWindow, regardless of content's width. + +:::note + +The `minWidth` can't be changed while the InfoWindow is open. + +::: + +### Events + +#### `onClose`: () => void + +This event is fired whenever the InfoWindow closes. This could be from +unmouting the InfoWindow component, pressing the escape key to close the +InfoWindow, or clicking the close button or removing the marker the +InfoWindow was anchored to. + +#### `onCloseClick`: () => void + +This event is fired when the close button was clicked. -To see an InfoWindow on the map, either the `position` property or the anchor needs to be set. +[gmp-infowindow]: https://developers.google.com/maps/documentation/javascript/infowindows +[gmp-infowindow-options]: https://developers.google.com/maps/documentation/javascript/reference/info-window#InfoWindowOptions +[react-dev-styling]: https://react.dev/reference/react-dom/components/common#applying-css-styles diff --git a/src/components/__tests__/info-window.test.tsx b/src/components/__tests__/info-window.test.tsx index 6dde8829..eb441238 100644 --- a/src/components/__tests__/info-window.test.tsx +++ b/src/components/__tests__/info-window.test.tsx @@ -1,49 +1,145 @@ import React from 'react'; -import {cleanup, render} from '@testing-library/react'; -import {initialize, InfoWindow} from '@googlemaps/jest-mocks'; +import {cleanup, queryByTestId, render} from '@testing-library/react'; +import {initialize} from '@googlemaps/jest-mocks'; import '@testing-library/jest-dom'; -import {Map as GoogleMap} from '../map'; -import {APIProvider} from '../api-provider'; import {InfoWindow as InfoWindowComponent} from '../info-window'; +import {useMap} from '../../hooks/use-map'; +import {useMapsLibrary} from '../../hooks/use-maps-library'; import {waitForMockInstance} from './__utils__/wait-for-mock-instance'; -jest.mock('../../libraries/google-maps-api-loader'); +jest.mock('../../hooks/use-map'); +jest.mock('../../hooks/use-maps-library'); -let wrapper: ({children}: {children: React.ReactNode}) => JSX.Element | null; +let createInfowindowSpy: jest.Mock; +let useMapMock: jest.MockedFn; +let useMapsLibraryMock: jest.MockedFn; -beforeEach(() => { - // initialize the google maps mock +beforeEach(async () => { initialize(); + jest.resetAllMocks(); - // Create wrapper component - wrapper = ({children}: {children: React.ReactNode}) => ( - - - {children} - - - ); + useMapMock = jest.mocked(useMap); + useMapsLibraryMock = jest.mocked(useMapsLibrary); + + createInfowindowSpy = jest.fn().mockName('InfoWindow.constructor'); + google.maps.InfoWindow = class extends google.maps.InfoWindow { + constructor(o?: google.maps.InfoWindowOptions) { + createInfowindowSpy(o); + super(o); + } + }; }); afterEach(() => { cleanup(); }); -test('info window should be initialized', async () => { - render( - Hi, - { - wrapper - } +test('InfoWindow creates a InfoWindow instance and passes options', async () => { + useMapsLibraryMock.mockReturnValue(null); + useMapMock.mockReturnValue(null); + + const {rerender} = render( + + ); + + expect(useMapsLibraryMock).toHaveBeenCalledWith('maps'); + expect(createInfowindowSpy).not.toHaveBeenCalled(); + + const mapsLib = google.maps; + useMapsLibraryMock.mockReturnValue(mapsLib); + + rerender( + ); - const infoWindow = await waitForMockInstance(InfoWindow); + expect(createInfowindowSpy).toHaveBeenCalled(); + + const [actualOptions] = createInfowindowSpy.mock.calls[0]; + expect(actualOptions).toMatchObject({ + ariaLabel: 'ariaLabel', + position: {lat: 1, lng: 2}, + minWidth: 200, + maxWidth: 300, + zIndex: 99, + disableAutoPan: true + }); + + expect(actualOptions.pixelOffset).toBeInstanceOf(google.maps.Size); + expect(actualOptions.pixelOffset.width).toBe(5); + expect(actualOptions.pixelOffset.height).toBe(6); + + const infoWindow = jest.mocked( + await waitForMockInstance(google.maps.InfoWindow) + ); expect(infoWindow).toBeDefined(); + expect(infoWindow.setOptions).not.toHaveBeenCalled(); +}); + +test('InfoWindow should render content into detached node', async () => { + const mapsLib = google.maps; + useMapsLibraryMock.mockReturnValue(mapsLib); + + const mockMap = new google.maps.Map(document.createElement('div')); + useMapMock.mockReturnValue(mockMap); + + const marker = new google.maps.marker.AdvancedMarkerElement(); + + render( + + Hello World! + + ); + + const infoWindow = jest.mocked( + await waitForMockInstance(google.maps.InfoWindow) + ); + + // assert .open() was called with correct options + expect(infoWindow.open).toHaveBeenCalled(); + const [openOptions] = infoWindow.open.mock.calls[0] as [ + google.maps.InfoWindowOpenOptions + ]; + expect(openOptions.map).toBe(mockMap); + expect(openOptions.anchor).toBe(marker); + expect(openOptions.shouldFocus).toBe(false); + + // assert setContent was called with the content element + expect(infoWindow.setContent).toHaveBeenCalledTimes(1); + const [contentEl] = infoWindow.setContent.mock.calls[0] as [HTMLElement]; + expect(contentEl).toBeInstanceOf(HTMLElement); + + // style and className should be applied to the content element + expect(contentEl).toHaveClass('infowindow-content'); + expect(contentEl).toHaveStyle({backgroundColor: 'red', padding: '8px'}); + + // child nodes should have been rendered + expect(queryByTestId(contentEl, 'content')).toBeTruthy(); + expect(queryByTestId(contentEl, 'content')).toHaveTextContent('Hello World!'); + + expect(infoWindow.open).toHaveBeenCalled(); }); -test.todo('info window should have a position'); -test.todo('info window gets position from marker'); -test.todo('info window should have content'); +test.todo('InfoWindow updates options and content correctly'); +test.todo('InfoWindow cleanup'); +test.todo('InfoWindow events'); diff --git a/src/components/info-window.tsx b/src/components/info-window.tsx index 77b6f28c..6f6fc9d0 100644 --- a/src/components/info-window.tsx +++ b/src/components/info-window.tsx @@ -1,90 +1,140 @@ /* eslint-disable complexity */ -import React, {PropsWithChildren, useEffect, useRef, useState} from 'react'; +import React, { + CSSProperties, + PropsWithChildren, + useEffect, + useRef, + useState +} from 'react'; import {createPortal} from 'react-dom'; import {useMap} from '../hooks/use-map'; - -/** - * Props for the Info Window Component - */ -export type InfoWindowProps = google.maps.InfoWindowOptions & { - onCloseClick?: () => void; +import {useMapsEventListener} from '../hooks/use-maps-event-listener'; +import {setValueForStyles} from '../libraries/set-value-for-styles'; +import {useMapsLibrary} from '../hooks/use-maps-library'; +import {useDeepCompareEffect} from '../libraries/use-deep-compare-effect'; + +export type InfoWindowProps = Omit< + google.maps.InfoWindowOptions, + 'content' | 'pixelOffset' +> & { + style?: CSSProperties; + className?: string; anchor?: google.maps.Marker | google.maps.marker.AdvancedMarkerElement | null; + pixelOffset?: [number, number]; shouldFocus?: boolean; + onClose?: () => void; + onCloseClick?: () => void; }; /** * Component to render a Google Maps Info Window */ export const InfoWindow = (props: PropsWithChildren) => { - const {children, anchor, shouldFocus, onCloseClick, ...infoWindowOptions} = - props; - const map = useMap(); - - const infoWindowRef = useRef(null); - const [contentContainer, setContentContainer] = - useState(null); - - // create infowindow once map is available - useEffect(() => { - if (!map) return; + const { + // content options + children, + style, + className, + pixelOffset, + + // open options + anchor, + shouldFocus, + // events + onClose, + onCloseClick, + + ...infoWindowOptions + } = props; + + // ## create infowindow instance once the mapsLibrary is available. + const mapsLibrary = useMapsLibrary('maps'); + const [infoWindow, setInfoWindow] = useState( + null + ); + const [contentContainer, setContentContainer] = useState( + null + ); - const newInfowindow = new google.maps.InfoWindow(infoWindowOptions); + useEffect( + () => { + if (!mapsLibrary) return; - // Add content to info window - const el = document.createElement('div'); - newInfowindow.setContent(el); + if (pixelOffset) { + (infoWindowOptions as google.maps.InfoWindowOptions).pixelOffset = + new google.maps.Size(pixelOffset[0], pixelOffset[1]); + } - infoWindowRef.current = newInfowindow; - setContentContainer(el); + // intentionally shadowing the state variables here + const infoWindow = new google.maps.InfoWindow(infoWindowOptions); + const contentContainer = document.createElement('div'); - // Cleanup info window and event listeners on unmount - return () => { - google.maps.event.clearInstanceListeners(newInfowindow); + infoWindow.setContent(contentContainer); - newInfowindow.close(); - el.remove(); + setInfoWindow(infoWindow); + setContentContainer(contentContainer); - setContentContainer(null); - }; + // cleanup: remove infoWindow, all event listeners and contentElement + return () => { + google.maps.event.clearInstanceListeners(infoWindow); + infoWindow.close(); + contentContainer.remove(); + setInfoWindow(null); + setContentContainer(null); + }; + }, // `infoWindowOptions` is missing from dependencies: // - // we don't want to re-render a whole new infowindow - // when the options change to prevent flickering. - // Update of infoWindow options is handled in the useEffect below. + // We don't want to re-create the infowindow instance + // when the options change. + // Updating the options is handled in the useEffect below. // // eslint-disable-next-line react-hooks/exhaustive-deps - }, [map, children]); + [mapsLibrary] + ); - // Update infoWindowOptions + // ## update className and styles for `contentContainer` + // stores previously applied style properties, so they can be removed when unset + const prevStyleRef = useRef(null); useEffect(() => { - infoWindowRef.current?.setOptions(infoWindowOptions); - }, [infoWindowOptions]); + if (!contentContainer) return; - // Handle the close click callback - useEffect(() => { - if (!infoWindowRef.current) return; + setValueForStyles(contentContainer, style || null, prevStyleRef.current); + prevStyleRef.current = style || null; - let listener: google.maps.MapsEventListener | null = null; + if (className !== contentContainer.className) + contentContainer.className = className || ''; + }, [contentContainer, className, style]); - if (onCloseClick) { - listener = google.maps.event.addListener( - infoWindowRef.current, - 'closeclick', - onCloseClick - ); - } + // ## update options + useDeepCompareEffect( + () => { + if (!infoWindow) return; + + if (pixelOffset) { + (infoWindowOptions as google.maps.InfoWindowOptions).pixelOffset = + new google.maps.Size(pixelOffset[0], pixelOffset[1]); + } - return () => { - if (listener) listener.remove(); - }; - }, [onCloseClick]); + infoWindow.setOptions(infoWindowOptions); + }, - // Open info window after content container is set + // dependency `infoWindow` isn't needed since options are passed to the constructor + // eslint-disable-next-line react-hooks/exhaustive-deps + [infoWindowOptions] + ); + + // ## bind event handlers + useMapsEventListener(infoWindow, 'close', onClose); + useMapsEventListener(infoWindow, 'closeclick', onCloseClick); + + // ## open info window when content and map are available + const map = useMap(); useEffect(() => { - // anchor === null means an anchor is defined but not ready yet. - if (!contentContainer || !infoWindowRef.current || anchor === null) return; + // `anchor === null` means an anchor is defined but not ready yet. + if (!contentContainer || !infoWindow || anchor === null) return; const openOptions: google.maps.InfoWindowOpenOptions = {map}; @@ -96,8 +146,8 @@ export const InfoWindow = (props: PropsWithChildren) => { openOptions.shouldFocus = shouldFocus; } - infoWindowRef.current.open(openOptions); - }, [contentContainer, infoWindowRef, anchor, map, shouldFocus]); + infoWindow.open(openOptions); + }, [infoWindow, contentContainer, anchor, map, shouldFocus]); return ( <>{contentContainer !== null && createPortal(children, contentContainer)} diff --git a/src/hooks/use-maps-event-listener.ts b/src/hooks/use-maps-event-listener.ts new file mode 100644 index 00000000..1525b214 --- /dev/null +++ b/src/hooks/use-maps-event-listener.ts @@ -0,0 +1,19 @@ +import {useEffect} from 'react'; + +/** + * Internally used to bind events to google maps API objects. + * @internal + */ +export function useMapsEventListener( + target?: google.maps.MVCObject | null, + name?: string, + callback?: ((arg?: unknown) => void) | null +) { + useEffect(() => { + if (!target || !name || !callback) return; + + const listener = google.maps.event.addListener(target, name, callback); + + return () => listener?.remove(); + }, [target, name, callback]); +} diff --git a/src/libraries/set-value-for-styles.ts b/src/libraries/set-value-for-styles.ts new file mode 100644 index 00000000..20fe5233 --- /dev/null +++ b/src/libraries/set-value-for-styles.ts @@ -0,0 +1,167 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* + * The code in this file was adapted from the internal react-dom-bindings package. + * https://github.com/facebook/react/tree/4508873393058e86bed308b56e49ec883ece59d1/packages/react-dom-bindings + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import {CSSProperties} from 'react'; + +export function setValueForStyles( + element: HTMLElement, + styles: CSSProperties | null, + prevStyles: CSSProperties | null +) { + if (styles != null && typeof styles !== 'object') { + throw new Error( + 'The `style` prop expects a mapping from style properties to values, ' + + "not a string. For example, style={{marginRight: spacing + 'em'}} when " + + 'using JSX.' + ); + } + + const elementStyle = element.style; + + // without `prevStyles`, just set all values + if (prevStyles == null) { + if (styles == null) return; + + for (const styleName in styles) { + if (!styles.hasOwnProperty(styleName)) continue; + + setValueForStyle( + elementStyle, + styleName, + styles[styleName as keyof CSSProperties] + ); + } + + return; + } + + // unset all styles in `prevStyles` that aren't in `styles` + for (const styleName in prevStyles) { + if ( + prevStyles.hasOwnProperty(styleName) && + (styles == null || !styles.hasOwnProperty(styleName)) + ) { + // Clear style + const isCustomProperty = styleName.indexOf('--') === 0; + if (isCustomProperty) { + elementStyle.setProperty(styleName, ''); + } else if (styleName === 'float') { + elementStyle.cssFloat = ''; + } else { + elementStyle[styleName as any] = ''; + } + } + } + + // only assign values from `styles` that are different from `prevStyles` + if (styles == null) return; + + for (const styleName in styles) { + const value = styles[styleName as keyof CSSProperties]; + if ( + styles.hasOwnProperty(styleName) && + prevStyles[styleName as keyof CSSProperties] !== value + ) { + setValueForStyle(elementStyle, styleName, value); + } + } +} + +function setValueForStyle( + elementStyle: CSSStyleDeclaration, + styleName: string, + value: unknown +) { + const isCustomProperty = styleName.indexOf('--') === 0; + + // falsy values will unset the style property + if (value == null || typeof value === 'boolean' || value === '') { + if (isCustomProperty) { + elementStyle.setProperty(styleName, ''); + } else if (styleName === 'float') { + elementStyle.cssFloat = ''; + } else { + elementStyle[styleName as any] = ''; + } + } + + // custom properties can't be directly assigned + else if (isCustomProperty) { + elementStyle.setProperty(styleName, value as string); + } + + // numeric values are treated as 'px' unless the style property expects unitless numbers + else if ( + typeof value === 'number' && + value !== 0 && + !isUnitlessNumber(styleName) + ) { + elementStyle[styleName as any] = value + 'px'; // Presumes implicit 'px' suffix for unitless numbers + } + + // everything else can just be assigned + else { + if (styleName === 'float') { + elementStyle.cssFloat = value as string; + } else { + elementStyle[styleName as any] = ('' + value).trim(); + } + } +} + +// CSS properties which accept numbers but are not in units of "px". +const unitlessNumbers = new Set([ + 'animationIterationCount', + 'aspectRatio', + 'borderImageOutset', + 'borderImageSlice', + 'borderImageWidth', + 'boxFlex', + 'boxFlexGroup', + 'boxOrdinalGroup', + 'columnCount', + 'columns', + 'flex', + 'flexGrow', + 'flexPositive', + 'flexShrink', + 'flexNegative', + 'flexOrder', + 'gridArea', + 'gridRow', + 'gridRowEnd', + 'gridRowSpan', + 'gridRowStart', + 'gridColumn', + 'gridColumnEnd', + 'gridColumnSpan', + 'gridColumnStart', + 'fontWeight', + 'lineClamp', + 'lineHeight', + 'opacity', + 'order', + 'orphans', + 'scale', + 'tabSize', + 'widows', + 'zIndex', + 'zoom', + 'fillOpacity', // SVG-related properties + 'floodOpacity', + 'stopOpacity', + 'strokeDasharray', + 'strokeDashoffset', + 'strokeMiterlimit', + 'strokeOpacity', + 'strokeWidth' +]); +function isUnitlessNumber(name: string): boolean { + return unitlessNumbers.has(name); +}