{showLabel ? buttonEl :
{buttonEl}}
diff --git a/src/App/components/MapButton/MapButton.module.scss b/src/App/components/MapButton/MapButton.module.scss
index 3ff8a5ce..9a1b9581 100755
--- a/src/App/components/MapButton/MapButton.module.scss
+++ b/src/App/components/MapButton/MapButton.module.scss
@@ -138,16 +138,16 @@
}
.im-o-app__right {
- .im-c-button-wrapper--group-start,
- .im-c-button-wrapper--group-middle {
- margin-bottom: calc(-1 * var(--divider-gap));
+ .im-c-button-group {
+ display: flex;
+ flex-direction: column;
}
- .im-c-button-wrapper--group-start .im-c-map-button {
+ .im-c-button-group .im-c-button-wrapper:first-child .im-c-map-button {
@include tools.border-focus-corner-override($corners: 'top');
}
- .im-c-button-wrapper--group-middle .im-c-map-button {
+ .im-c-button-group .im-c-button-wrapper:not(:first-child):not(:last-child) .im-c-map-button {
@include tools.border-focus-corner-override($corners: 'none');
&::before {
@@ -155,23 +155,23 @@
}
}
- .im-c-button-wrapper--group-end .im-c-map-button {
+ .im-c-button-group .im-c-button-wrapper:last-child .im-c-map-button {
margin-top: 0;
@include tools.border-focus-corner-override($corners: 'bottom');
}
}
.im-o-app__top {
- .im-c-button-wrapper--group-start,
- .im-c-button-wrapper--group-middle {
- margin-right: calc(-1 * var(--divider-gap));
+ .im-c-button-group {
+ display: flex;
+ flex-direction: row;
}
- .im-c-button-wrapper--group-start .im-c-map-button {
+ .im-c-button-group .im-c-button-wrapper:first-child .im-c-map-button {
@include tools.border-focus-corner-override($corners: 'left');
}
- .im-c-button-wrapper--group-middle .im-c-map-button {
+ .im-c-button-group .im-c-button-wrapper:not(:first-child):not(:last-child) .im-c-map-button {
@include tools.border-focus-corner-override($corners: 'none');
&::before {
@@ -179,7 +179,7 @@
}
}
- .im-c-button-wrapper--group-end .im-c-map-button {
+ .im-c-button-group .im-c-button-wrapper:last-child .im-c-map-button {
@include tools.border-focus-corner-override($corners: 'right');
}
}
diff --git a/src/App/components/MapButton/MapButton.test.jsx b/src/App/components/MapButton/MapButton.test.jsx
index 279a5b50..d59d5167 100755
--- a/src/App/components/MapButton/MapButton.test.jsx
+++ b/src/App/components/MapButton/MapButton.test.jsx
@@ -66,15 +66,6 @@ describe('MapButton', () => {
expect(container.firstChild).toHaveStyle('display: none')
})
- it.each([
- ['groupStart', 'im-c-button-wrapper--group-start'],
- ['groupMiddle', 'im-c-button-wrapper--group-middle'],
- ['groupEnd', 'im-c-button-wrapper--group-end']
- ])('applies wrapper %s class', (prop, className) => {
- const { container } = renderButton({ [prop]: true })
- expect(container.firstChild).toHaveClass(className)
- })
-
it('handles panelId aria attributes', () => {
renderButton({ panelId: 'Settings', idPrefix: 'prefix', isDisabled: true, isPanelOpen: false })
const button = getButton()
diff --git a/src/App/components/Panel/Panel.jsx b/src/App/components/Panel/Panel.jsx
index 996ead96..1548fa7b 100755
--- a/src/App/components/Panel/Panel.jsx
+++ b/src/App/components/Panel/Panel.jsx
@@ -8,9 +8,9 @@ import { Icon } from '../Icon/Icon'
const computePanelState = (bpConfig, triggeringElement) => {
const isAside = bpConfig.slot === 'side' && bpConfig.open && !bpConfig.modal
- const isDialog = !isAside && bpConfig.dismissable
+ const isDialog = !isAside && bpConfig.dismissible
const isModal = bpConfig.modal === true
- const isDismissable = bpConfig.dismissable !== false
+ const isDismissable = bpConfig.dismissible !== false
const shouldFocus = Boolean(isModal || triggeringElement)
const buttonContainerEl = bpConfig.slot.endsWith('button') ? triggeringElement?.parentNode : undefined
return { isAside, isDialog, isModal, isDismissable, shouldFocus, buttonContainerEl }
diff --git a/src/App/components/Panel/Panel.test.jsx b/src/App/components/Panel/Panel.test.jsx
index 8efca61c..c2cfc008 100755
--- a/src/App/components/Panel/Panel.test.jsx
+++ b/src/App/components/Panel/Panel.test.jsx
@@ -32,7 +32,7 @@ describe('Panel', () => {
const renderPanel = (config = {}, props = {}) => {
const panelConfig = {
- desktop: { slot: 'side', open: true, dismissable: false, modal: false, showLabel: true },
+ desktop: { slot: 'side', open: true, dismissible: false, modal: false, showLabel: true },
...config
}
return render(
)
@@ -48,17 +48,17 @@ describe('Panel', () => {
})
it('renders visually hidden label when showLabel=false', () => {
- renderPanel({ desktop: { slot: 'side', open: true, dismissable: false, modal: false, showLabel: false } })
+ renderPanel({ desktop: { slot: 'side', open: true, dismissible: false, modal: false, showLabel: false } })
expect(screen.getByText('Settings')).toHaveClass('im-u-visually-hidden')
})
- it('applies offset class to body when showLabel=false and dismissable', () => {
- renderPanel({ desktop: { slot: 'side', dismissable: true, open: false, showLabel: false } })
+ it('applies offset class to body when showLabel=false and dismissible', () => {
+ renderPanel({ desktop: { slot: 'side', dismissible: true, open: false, showLabel: false } })
expect(screen.getByRole('dialog').querySelector('.im-c-panel__body')).toHaveClass('im-c-panel__body--offset')
})
it('applies width style if provided', () => {
- renderPanel({ desktop: { slot: 'side', dismissable: true, open: true, width: '300px' } })
+ renderPanel({ desktop: { slot: 'side', dismissible: true, open: true, width: '300px' } })
expect(screen.getByRole('complementary')).toHaveStyle({ width: '300px' })
})
@@ -81,23 +81,23 @@ describe('Panel', () => {
})
describe('role and aria attributes', () => {
- it('renders region role for non-dismissable panels', () => {
+ it('renders region role for non-dismissible panels', () => {
renderPanel()
expect(screen.getByRole('region')).toBeInTheDocument()
})
- it('renders dialog role for dismissable non-aside panels', () => {
- renderPanel({ desktop: { slot: 'side', dismissable: true, open: false } })
+ it('renders dialog role for dismissible non-aside panels', () => {
+ renderPanel({ desktop: { slot: 'side', dismissible: true, open: false } })
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
- it('renders complementary role for dismissable aside panels', () => {
- renderPanel({ desktop: { slot: 'side', open: true, dismissable: true } })
+ it('renders complementary role for dismissible aside panels', () => {
+ renderPanel({ desktop: { slot: 'side', open: true, dismissible: true } })
expect(screen.getByRole('complementary')).toBeInTheDocument()
})
it('sets aria-modal and tabIndex for modal dialogs', () => {
- renderPanel({ desktop: { slot: 'overlay', dismissable: true, modal: true } })
+ renderPanel({ desktop: { slot: 'overlay', dismissible: true, modal: true } })
const dialog = screen.getByRole('dialog')
expect(dialog).toHaveAttribute('aria-modal', 'true')
expect(dialog).toHaveAttribute('tabIndex', '-1')
@@ -110,7 +110,7 @@ describe('Panel', () => {
const triggeringElement = { focus: focusMock, parentNode: document.createElement('div') }
renderPanel(
- { desktop: { slot: 'top-button', dismissable: true, open: false } },
+ { desktop: { slot: 'top-button', dismissible: true, open: false } },
{ props: { triggeringElement } }
)
@@ -124,7 +124,7 @@ describe('Panel', () => {
const triggeringElement = { focus: focusMock, parentNode: document.createElement('div') }
renderPanel(
- { desktop: { slot: 'overlay', dismissable: true, modal: true } },
+ { desktop: { slot: 'overlay', dismissible: true, modal: true } },
{ props: { triggeringElement } }
)
@@ -133,7 +133,7 @@ describe('Panel', () => {
})
it('falls back to viewportRef focus when no triggeringElement', () => {
- renderPanel({ desktop: { slot: 'side', dismissable: true, open: false } })
+ renderPanel({ desktop: { slot: 'side', dismissible: true, open: false } })
fireEvent.click(screen.getByRole('button', { name: 'Close Settings' }))
expect(layoutRefs.viewportRef.current.focus).toHaveBeenCalled()
diff --git a/src/App/components/Viewport/MapController.jsx b/src/App/components/Viewport/MapController.jsx
index ac860b9c..92b4e596 100755
--- a/src/App/components/Viewport/MapController.jsx
+++ b/src/App/components/Viewport/MapController.jsx
@@ -38,7 +38,8 @@ export const MapController = ({ mapContainerRef }) => {
center: initialState.center,
zoom: initialState.zoom,
bounds: initialState.bounds,
- mapStyle
+ mapStyle,
+ mapSize
})
})
diff --git a/src/App/hooks/useLayoutMeasurements.js b/src/App/hooks/useLayoutMeasurements.js
index da199a85..707247ca 100755
--- a/src/App/hooks/useLayoutMeasurements.js
+++ b/src/App/hooks/useLayoutMeasurements.js
@@ -81,7 +81,7 @@ export function useLayoutMeasurements () {
// --------------------------------
// 3. Recaluclate CSS vars when elements resize
// --------------------------------
- useResizeObserver([bannerRef, mainRef, topRef, actionsRef, footerRef], () => {
+ useResizeObserver([bannerRef, mainRef, topRef, topLeftColRef, topRightColRef, actionsRef, footerRef], () => {
requestAnimationFrame(() => {
calculateLayout()
})
diff --git a/src/App/hooks/useLayoutMeasurements.test.js b/src/App/hooks/useLayoutMeasurements.test.js
index 552fffc3..4280ef10 100644
--- a/src/App/hooks/useLayoutMeasurements.test.js
+++ b/src/App/hooks/useLayoutMeasurements.test.js
@@ -114,7 +114,7 @@ describe('useLayoutMeasurements', () => {
const { layoutRefs } = setup()
renderHook(() => useLayoutMeasurements())
expect(useResizeObserver).toHaveBeenCalledWith(
- [layoutRefs.bannerRef, layoutRefs.mainRef, layoutRefs.topRef, layoutRefs.actionsRef, layoutRefs.footerRef],
+ [layoutRefs.bannerRef, layoutRefs.mainRef, layoutRefs.topRef, layoutRefs.topLeftColRef, layoutRefs.topRightColRef, layoutRefs.actionsRef, layoutRefs.footerRef],
expect.any(Function)
)
layoutRefs.appContainerRef.current.style.setProperty.mockClear()
diff --git a/src/App/hooks/useMapProviderOverrides.js b/src/App/hooks/useMapProviderOverrides.js
index 6ba654ff..dfef9fbd 100755
--- a/src/App/hooks/useMapProviderOverrides.js
+++ b/src/App/hooks/useMapProviderOverrides.js
@@ -2,12 +2,13 @@ import { useEffect, useRef } from 'react'
import { useConfig } from '../store/configContext.js'
import { useApp } from '../store/appContext.js'
import { useMap } from '../store/mapContext.js'
+import { EVENTS as events } from '../../config/events.js'
import { getSafeZoneInset } from '../../utils/getSafeZoneInset.js'
import { scalePoints } from '../../utils/scalePoints.js'
import { scaleFactor } from '../../config/appConfig.js'
export const useMapProviderOverrides = () => {
- const { mapProvider } = useConfig()
+ const { mapProvider, eventBus } = useConfig()
const { dispatch: appDispatch, layoutRefs } = useApp()
const { mapSize } = useMap()
@@ -67,4 +68,23 @@ export const useMapProviderOverrides = () => {
mapProvider.setView = originalSetView
}
}, [mapProvider, appDispatch, layoutRefs, mapSize])
+
+ // Forward public API events to the (overridden) mapProvider methods so that
+ // interactiveMap.fitToBounds() and interactiveMap.setView() respect safe zone padding.
+ useEffect(() => {
+ if (!mapProvider || !eventBus) {
+ return undefined
+ }
+
+ const handleFitToBounds = (bbox) => mapProvider.fitToBounds(bbox)
+ const handleSetView = (opts) => mapProvider.setView(opts)
+
+ eventBus.on(events.MAP_FIT_TO_BOUNDS, handleFitToBounds)
+ eventBus.on(events.MAP_SET_VIEW, handleSetView)
+
+ return () => {
+ eventBus.off(events.MAP_FIT_TO_BOUNDS, handleFitToBounds)
+ eventBus.off(events.MAP_SET_VIEW, handleSetView)
+ }
+ }, [mapProvider, eventBus])
}
diff --git a/src/App/hooks/useMapProviderOverrides.test.js b/src/App/hooks/useMapProviderOverrides.test.js
index dc645f85..72440731 100644
--- a/src/App/hooks/useMapProviderOverrides.test.js
+++ b/src/App/hooks/useMapProviderOverrides.test.js
@@ -22,15 +22,21 @@ const setup = (overrides = {}) => {
setPadding: jest.fn(),
...overrides.mapProvider
}
+ const capturedHandlers = {}
+ const eventBus = {
+ on: jest.fn((event, handler) => { capturedHandlers[event] = handler }),
+ off: jest.fn(),
+ ...overrides.eventBus
+ }
- useConfig.mockReturnValue({ mapProvider, ...overrides.config })
+ useConfig.mockReturnValue({ mapProvider, eventBus, ...overrides.config })
useApp.mockReturnValue({ dispatch, layoutRefs, ...overrides.app })
useMap.mockReturnValue({ mapSize: 'md', ...overrides.map })
getSafeZoneInset.mockReturnValue({ top: 10, right: 5, bottom: 15, left: 5 })
scalePoints.mockReturnValue({ top: 20, right: 10, bottom: 30, left: 10 })
- return { dispatch, layoutRefs, mapProvider }
+ return { dispatch, layoutRefs, mapProvider, eventBus, capturedHandlers }
}
describe('useMapProviderOverrides', () => {
@@ -133,4 +139,47 @@ describe('useMapProviderOverrides', () => {
expect(mapProvider.fitToBounds).not.toBe(firstOverride)
})
+
+ test('subscribes to MAP_FIT_TO_BOUNDS and MAP_SET_VIEW on eventBus', () => {
+ const { eventBus } = setup()
+ renderHook(() => useMapProviderOverrides())
+
+ expect(eventBus.on).toHaveBeenCalledWith('map:fittobounds', expect.any(Function))
+ expect(eventBus.on).toHaveBeenCalledWith('map:setview', expect.any(Function))
+ })
+
+ test('MAP_FIT_TO_BOUNDS event forwards bbox to mapProvider.fitToBounds', () => {
+ const { mapProvider, capturedHandlers } = setup()
+ const originalFitToBounds = mapProvider.fitToBounds
+ renderHook(() => useMapProviderOverrides())
+
+ capturedHandlers['map:fittobounds']([0, 0, 1, 1])
+
+ expect(originalFitToBounds).toHaveBeenCalledWith([0, 0, 1, 1])
+ })
+
+ test('MAP_SET_VIEW event forwards opts to mapProvider.setView', () => {
+ const { mapProvider, capturedHandlers } = setup()
+ const originalSetView = mapProvider.setView
+ renderHook(() => useMapProviderOverrides())
+
+ capturedHandlers['map:setview']({ center: [1, 2], zoom: 10 })
+
+ expect(originalSetView).toHaveBeenCalledWith({ center: [1, 2], zoom: 10 })
+ })
+
+ test('unsubscribes from MAP_FIT_TO_BOUNDS and MAP_SET_VIEW on unmount', () => {
+ const { eventBus } = setup()
+ const { unmount } = renderHook(() => useMapProviderOverrides())
+
+ unmount()
+
+ expect(eventBus.off).toHaveBeenCalledWith('map:fittobounds', expect.any(Function))
+ expect(eventBus.off).toHaveBeenCalledWith('map:setview', expect.any(Function))
+ })
+
+ test('skips event subscriptions when eventBus is null', () => {
+ setup({ config: { eventBus: null } })
+ expect(() => renderHook(() => useMapProviderOverrides())).not.toThrow()
+ })
})
diff --git a/src/App/layout/Layout.jsx b/src/App/layout/Layout.jsx
index 50f7f4f5..eb4e39c2 100755
--- a/src/App/layout/Layout.jsx
+++ b/src/App/layout/Layout.jsx
@@ -94,12 +94,12 @@ export const Layout = () => {
+
- {/* NOSONAR - div with role="group" is correct for a button group */}
+ {sorted.map(btn => renderButton({ btn, appState, appConfig, evaluateProp }))}
+
+ )
+ })
+ }
+
+ return result
}
export {
mapButtons,
getMatchingButtons,
- renderButton
+ renderButton,
+ resolveGroupName,
+ resolveGroupLabel,
+ resolveGroupOrder
}
diff --git a/src/App/renderer/mapButtons.test.js b/src/App/renderer/mapButtons.test.js
index 6f2c8e63..ffacd856 100755
--- a/src/App/renderer/mapButtons.test.js
+++ b/src/App/renderer/mapButtons.test.js
@@ -1,5 +1,5 @@
import React from 'react'
-import { mapButtons, getMatchingButtons, renderButton } from './mapButtons.js'
+import { mapButtons, getMatchingButtons, renderButton, resolveGroupName, resolveGroupLabel, resolveGroupOrder } from './mapButtons.js'
import { getPanelConfig } from '../registry/panelRegistry.js'
jest.mock('../registry/buttonRegistry.js')
@@ -44,6 +44,52 @@ describe('mapButtons module', () => {
getPanelConfig.mockReturnValue({})
})
+ // -------------------------
+ // resolveGroup* helper tests
+ // -------------------------
+ describe('resolveGroupName', () => {
+ it('returns null when group is null or undefined', () => {
+ expect(resolveGroupName(null)).toBeNull()
+ expect(resolveGroupName(undefined)).toBeNull()
+ })
+ it('returns the string when group is a string', () => {
+ expect(resolveGroupName('g1')).toBe('g1')
+ })
+ it('returns group.name when group is an object', () => {
+ expect(resolveGroupName({ name: 'g1' })).toBe('g1')
+ expect(resolveGroupName({ name: undefined })).toBeNull()
+ })
+ })
+
+ describe('resolveGroupLabel', () => {
+ it('returns empty string when group is falsy', () => {
+ expect(resolveGroupLabel(null)).toBe('')
+ expect(resolveGroupLabel(undefined)).toBe('')
+ })
+ it('returns the string itself when group is a string', () => {
+ expect(resolveGroupLabel('My Group')).toBe('My Group')
+ })
+ it('returns group.label when provided, else group.name, else empty string', () => {
+ expect(resolveGroupLabel({ name: 'g1', label: 'Group One' })).toBe('Group One')
+ expect(resolveGroupLabel({ name: 'g1' })).toBe('g1')
+ expect(resolveGroupLabel({ order: 5 })).toBe('')
+ })
+ })
+
+ describe('resolveGroupOrder', () => {
+ it('returns 0 when group is falsy', () => {
+ expect(resolveGroupOrder(null)).toBe(0)
+ expect(resolveGroupOrder(undefined)).toBe(0)
+ })
+ it('returns 0 when group is a string', () => {
+ expect(resolveGroupOrder('g1')).toBe(0)
+ })
+ it('returns group.order when provided, else 0', () => {
+ expect(resolveGroupOrder({ name: 'g1', order: 5 })).toBe(5)
+ expect(resolveGroupOrder({ name: 'g1' })).toBe(0)
+ })
+ })
+
// -------------------------
// getMatchingButtons tests
// -------------------------
@@ -93,14 +139,14 @@ describe('mapButtons module', () => {
expect(getMatchingButtons({ buttonConfig: config, slot: 'header', appState, evaluateProp }).length).toBe(2)
})
- it('filters out panel-toggle button when panel is open and non-dismissable at current breakpoint', () => {
- const state = { ...appState, panelConfig: { myPanel: { desktop: { open: true, dismissable: false } } } }
+ it('filters out panel-toggle button when panel is open and non-dismissible at current breakpoint', () => {
+ const state = { ...appState, panelConfig: { myPanel: { desktop: { open: true, dismissible: false } } } }
const config = { b1: { ...baseBtn, panelId: 'myPanel' } }
expect(getMatchingButtons({ buttonConfig: config, slot: 'header', appState: state, evaluateProp }).length).toBe(0)
})
- it('includes panel-toggle button when panel is dismissable at current breakpoint', () => {
- const state = { ...appState, panelConfig: { myPanel: { desktop: { open: true, dismissable: true } } } }
+ it('includes panel-toggle button when panel is dismissible at current breakpoint', () => {
+ const state = { ...appState, panelConfig: { myPanel: { desktop: { open: true, dismissible: true } } } }
const config = { b1: { ...baseBtn, panelId: 'myPanel' } }
expect(getMatchingButtons({ buttonConfig: config, slot: 'header', appState: state, evaluateProp }).length).toBe(1)
})
@@ -110,19 +156,14 @@ describe('mapButtons module', () => {
// renderButton tests
// -------------------------
describe('renderButton', () => {
- const render = (config, state = appState, flags = {}) =>
- renderButton({ btn: ['id', config], appState: state, appConfig, evaluateProp, groupStart: false, groupMiddle: false, groupEnd: false, ...flags })
+ const render = (config, state = appState) =>
+ renderButton({ btn: ['id', config], appState: state, appConfig, evaluateProp })
it('renders a MapButton with correct basic props', () => {
const result = render(baseBtn)
expect(result.props).toMatchObject({ buttonId: 'id', iconId: 'i1', label: 'Btn', showLabel: true })
})
- it('applies group flags correctly', () => {
- const result = render(baseBtn, appState, { groupStart: true, groupEnd: true })
- expect(result.props).toMatchObject({ groupStart: true, groupEnd: true })
- })
-
it('evaluates dynamic label, iconId, and href via evaluateProp', () => {
const label = jest.fn(() => 'DynamicLabel')
const iconId = jest.fn(() => 'DynamicIcon')
@@ -204,21 +245,68 @@ describe('mapButtons module', () => {
expect(result[0]).toMatchObject({ id: 'b1', type: 'button', order: 1 })
})
- it('sets groupStart, groupMiddle, and groupEnd flags correctly for multiple buttons', () => {
+ it('renders grouped buttons as a single group item with role=group', () => {
+ appState.buttonConfig = ({
+ b1: { ...baseBtn, group: { name: 'g1', label: 'Group 1', order: 2 } },
+ b2: { ...baseBtn, desktop: { slot: 'header', order: 2 }, group: { name: 'g1', label: 'Group 1', order: 2 } }
+ })
+ const result = map()
+ expect(result).toHaveLength(1)
+ expect(result[0]).toMatchObject({ id: 'group-g1', type: 'group', order: 2 })
+ expect(result[0].element.props.role).toBe('group')
+ expect(result[0].element.props['aria-label']).toBe('Group 1')
+ })
+
+ it('uses group name as aria-label when no explicit label is provided', () => {
appState.buttonConfig = ({
- b1: { ...baseBtn, group: 'g1' },
- b2: { ...baseBtn, desktop: { slot: 'header', order: 2 }, group: 'g1' },
- b3: { ...baseBtn, desktop: { slot: 'header', order: 3 }, group: 'g1' }
+ b1: { ...baseBtn, group: { name: 'g1', order: 0 } },
+ b2: { ...baseBtn, group: { name: 'g1', order: 0 } }
})
const result = map()
- expect(result[0].element.props).toMatchObject({ groupStart: true })
- expect(result[1].element.props.groupMiddle).toBe(true)
- expect(result[2].element.props).toMatchObject({ groupEnd: true })
+ expect(result[0].element.props['aria-label']).toBe('g1')
})
- it('ignores singleton groups when calculating group flags', () => {
- appState.buttonConfig = ({ b1: { ...baseBtn, group: 'g1' } })
- expect(map()[0].element.props).toMatchObject({ groupStart: false, groupEnd: false })
+ it('sorts group members by intra-group order', () => {
+ appState.buttonConfig = ({
+ b1: { ...baseBtn, group: { name: 'g1', order: 0 }, desktop: { slot: 'header', order: 3 } },
+ b2: { ...baseBtn, group: { name: 'g1', order: 0 }, desktop: { slot: 'header', order: 1 } },
+ b3: { ...baseBtn, group: { name: 'g1', order: 0 }, desktop: { slot: 'header', order: 2 } }
+ })
+ const result = map()
+ expect(result).toHaveLength(1)
+ const children = result[0].element.props.children
+ expect(children[0].props.buttonId).toBe('b2')
+ expect(children[1].props.buttonId).toBe('b3')
+ expect(children[2].props.buttonId).toBe('b1')
+ })
+
+ it('renders singleton groups as regular buttons using group slot order', () => {
+ appState.buttonConfig = ({ b1: { ...baseBtn, group: { name: 'g1', label: 'Group 1', order: 3 } } })
+ const result = map()
+ expect(result).toHaveLength(1)
+ expect(result[0]).toMatchObject({ id: 'b1', type: 'button', order: 3 })
+ })
+
+ it('falls back to breakpoint order for singleton group when group order is 0', () => {
+ appState.buttonConfig = ({ b1: { ...baseBtn, desktop: { slot: 'header', order: 4 }, group: { name: 'g1', order: 0 } } })
+ expect(map()[0].order).toBe(4)
+ })
+
+ it('falls back to 0 for singleton group when both group order and breakpoint order are absent', () => {
+ appState.buttonConfig = ({ b1: { ...baseBtn, desktop: { slot: 'header' }, group: { name: 'g1', order: 0 } } })
+ expect(map()[0].order).toBe(0)
+ })
+
+ it('sorts group members treating missing breakpoint order as 0', () => {
+ appState.buttonConfig = ({
+ b1: { ...baseBtn, group: { name: 'g1', order: 0 }, desktop: { slot: 'header', order: 2 } },
+ b2: { ...baseBtn, group: { name: 'g1', order: 0 }, desktop: { slot: 'header' } },
+ b3: { ...baseBtn, group: { name: 'g1', order: 0 }, desktop: { slot: 'header', order: 1 } }
+ })
+ const children = map()[0].element.props.children
+ expect(children[0].props.buttonId).toBe('b2')
+ expect(children[1].props.buttonId).toBe('b3')
+ expect(children[2].props.buttonId).toBe('b1')
})
it('falls back to order 0 when order is not specified in breakpoint config', () => {
diff --git a/src/App/store/MapProvider.jsx b/src/App/store/MapProvider.jsx
index 2a3e5a8f..88d202cf 100755
--- a/src/App/store/MapProvider.jsx
+++ b/src/App/store/MapProvider.jsx
@@ -9,13 +9,10 @@ export const MapProvider = ({ options, children }) => {
const [state, dispatch] = useReducer(reducer, initialState(options))
const { eventBus } = options
- const mapProviderAPIRef = useRef(null)
- const hasEmittedMapReadyRef = useRef(false)
const isMapSizeInitialisedRef = useRef(false)
- const handleProviderReady = (mapProviderAPI) => {
- mapProviderAPIRef.current = mapProviderAPI
- dispatch({ type: 'SET_MAP_READY', payload: mapProviderAPI })
+ const handleMapReady = () => {
+ dispatch({ type: 'SET_MAP_READY' })
}
const handleInitMapStyles = (mapStyles) => {
@@ -37,33 +34,19 @@ export const MapProvider = ({ options, children }) => {
// Listen to eventBus and update state
useEffect(() => {
- eventBus.on(events.MAP_PROVIDER_READY, handleProviderReady)
+ eventBus.on(events.MAP_READY, handleMapReady)
eventBus.on(events.MAP_INIT_MAP_STYLES, handleInitMapStyles)
eventBus.on(events.MAP_SET_STYLE, handleSetMapStyle)
eventBus.on(events.MAP_SET_SIZE, handleSetMapSize)
return () => {
- eventBus.off(events.MAP_PROVIDER_READY, handleProviderReady)
+ eventBus.off(events.MAP_READY, handleMapReady)
eventBus.off(events.MAP_INIT_MAP_STYLES, handleInitMapStyles)
eventBus.off(events.MAP_SET_STYLE, handleSetMapStyle)
eventBus.off(events.MAP_SET_SIZE, handleSetMapSize)
}
}, [])
- // Emit consumer-facing map:ready once provider, mapStyle and mapSize are all settled.
- // Fires exactly once — the ref guard prevents re-emission if state later changes.
- useEffect(() => {
- if (!state.isMapReady || !state.mapStyle || !state.mapSize || hasEmittedMapReadyRef.current) {
- return
- }
- hasEmittedMapReadyRef.current = true
- eventBus.emit(events.MAP_READY, {
- ...mapProviderAPIRef.current,
- mapStyleId: state.mapStyle.id,
- mapSize: state.mapSize
- })
- }, [state.isMapReady, state.mapStyle, state.mapSize])
-
// Emit map:sizechange when mapSize changes, skipping the initial value.
useEffect(() => {
if (!state.mapSize) {
@@ -73,10 +56,7 @@ export const MapProvider = ({ options, children }) => {
isMapSizeInitialisedRef.current = true
return
}
- eventBus.emit(events.MAP_SIZE_CHANGE, {
- ...mapProviderAPIRef.current,
- mapSize: state.mapSize
- })
+ eventBus.emit(events.MAP_SIZE_CHANGE, { mapSize: state.mapSize })
}, [state.mapSize])
// Persist mapStyle and mapSize in localStorage
diff --git a/src/App/store/MapProvider.test.jsx b/src/App/store/MapProvider.test.jsx
index 1d5295db..c61c74e7 100755
--- a/src/App/store/MapProvider.test.jsx
+++ b/src/App/store/MapProvider.test.jsx
@@ -71,15 +71,15 @@ describe('MapProvider', () => {
expect(contextValue).toHaveProperty('isMapReady')
})
- test('subscribes to MAP_PROVIDER_READY instead of MAP_READY', () => {
+ test('subscribes to MAP_READY (not MAP_PROVIDER_READY)', () => {
render(