@defra/interactive-map 0.0.18-alpha → 0.0.19-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/css/docusaurus.css +58 -34
- package/dist/css/index.css +1 -1
- package/dist/esm/im-core.js +1 -1
- package/dist/esm/im-shell.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/api/panel-definition.md +16 -0
- package/docs/api.md +28 -4
- package/docs/assets/basic-map.jpg +0 -0
- package/docs/assets/button-first.jpg +0 -0
- package/docs/assets/maker-panel.jpg +0 -0
- package/docs/examples/add-marker-with-panel.mdx +59 -0
- package/docs/examples/basic-map.mdx +24 -0
- package/docs/examples/button-map.mdx +24 -0
- package/docs/examples/index.mdx +49 -0
- package/docs/index.mdx +1 -1
- package/docs/plugins/interact.md +32 -1
- package/docs/plugins.md +1 -1
- package/docusaurus.config.cjs +9 -1
- package/package.json +1 -1
- package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
- package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
- package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/css/index.css +2 -19
- package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/scale-bar/dist/css/index.css +1 -1
- package/plugins/beta/scale-bar/src/scaleBar.scss +1 -0
- package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/index.js +1 -1
- package/plugins/interact/src/InteractInit.jsx +5 -3
- package/plugins/interact/src/api/clear.js +1 -1
- package/plugins/interact/src/api/selectMarker.js +14 -0
- package/plugins/interact/src/api/selectMarker.test.js +25 -0
- package/plugins/interact/src/api/unselectMarker.js +14 -0
- package/plugins/interact/src/api/unselectMarker.test.js +14 -0
- package/plugins/interact/src/events.js +18 -30
- package/plugins/interact/src/events.test.js +113 -108
- package/plugins/interact/src/manifest.js +10 -2
- package/plugins/interact/src/reducer.js +36 -1
- package/plugins/interact/src/reducer.test.js +40 -1
- package/plugins/interact/src/utils/interactionModes.js +12 -0
- package/src/App/components/Panel/Panel.jsx +6 -6
- package/src/App/components/Panel/Panel.test.jsx +37 -0
- package/src/App/components/Viewport/Viewport.jsx +5 -15
- package/src/App/components/Viewport/Viewport.module.scss +2 -0
- package/src/App/components/Viewport/Viewport.test.jsx +16 -33
- package/src/App/hooks/useInterfaceAPI.js +7 -7
- package/src/App/hooks/useInterfaceAPI.test.js +15 -9
- package/src/App/hooks/useLayoutMeasurements.js +64 -72
- package/src/App/layout/Layout.jsx +1 -1
- package/src/App/layout/layout.module.scss +1 -8
- package/src/App/renderer/HtmlElementHost.jsx +10 -5
- package/src/App/renderer/mapPanels.js +2 -1
- package/src/App/store/appActionsMap.js +4 -4
- package/src/App/store/appActionsMap.test.js +10 -0
- package/src/InteractiveMap/InteractiveMap.js +59 -11
- package/src/InteractiveMap/InteractiveMap.test.js +126 -4
- package/src/InteractiveMap/domStateManager.js +18 -6
- package/src/InteractiveMap/domStateManager.test.js +21 -0
- package/src/InteractiveMap/historyManager.js +28 -16
- package/src/InteractiveMap/historyManager.test.js +17 -0
- package/src/config/appConfig.js +2 -1
- package/src/config/appConfig.test.js +3 -13
- package/src/config/defaults.js +2 -1
- package/src/config/events.js +20 -21
- package/src/services/closeApp.js +1 -10
- package/src/services/closeApp.test.js +3 -43
- package/src/types.js +6 -1
- package/src/utils/mapStateSync.js +48 -10
- package/src/utils/mapStateSync.test.js +29 -9
- package/docs/examples.mdx +0 -70
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
|
-
import { render,
|
|
2
|
+
import { render, cleanup } from '@testing-library/react'
|
|
3
3
|
import { Viewport } from './Viewport.jsx'
|
|
4
4
|
import { useConfig } from '../../store/configContext.js'
|
|
5
5
|
import { useApp } from '../../store/appContext.js'
|
|
@@ -23,15 +23,18 @@ jest.mock('../CrossHair/CrossHair', () => ({ CrossHair: jest.fn(() => <div data-
|
|
|
23
23
|
jest.mock('../Markers/Markers', () => ({ Markers: jest.fn(() => <div data-testid='markers' />) }))
|
|
24
24
|
|
|
25
25
|
describe('Viewport', () => {
|
|
26
|
-
let
|
|
26
|
+
let viewportEl
|
|
27
|
+
let mainEl
|
|
27
28
|
const mockMapProvider = { initMap: jest.fn(), updateMap: jest.fn(), clearHighlightedLabel: jest.fn() }
|
|
28
29
|
|
|
29
30
|
beforeEach(() => {
|
|
30
31
|
cleanup()
|
|
31
32
|
jest.clearAllMocks()
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
document.
|
|
34
|
+
viewportEl = document.createElement('div')
|
|
35
|
+
mainEl = document.createElement('div')
|
|
36
|
+
document.body.appendChild(viewportEl)
|
|
37
|
+
document.body.appendChild(mainEl)
|
|
35
38
|
|
|
36
39
|
// ---------------------------
|
|
37
40
|
// Hook mocks
|
|
@@ -47,7 +50,7 @@ describe('Viewport', () => {
|
|
|
47
50
|
interfaceType: 'desktop',
|
|
48
51
|
mode: 'default',
|
|
49
52
|
previousMode: 'default',
|
|
50
|
-
layoutRefs: {
|
|
53
|
+
layoutRefs: { mainRef: { current: mainEl }, viewportRef: { current: viewportEl }, safeZoneRef: { current: null } },
|
|
51
54
|
safeZoneInset: {}
|
|
52
55
|
})
|
|
53
56
|
|
|
@@ -75,14 +78,17 @@ describe('Viewport', () => {
|
|
|
75
78
|
useMapEvents.mockImplementation(() => {})
|
|
76
79
|
})
|
|
77
80
|
|
|
78
|
-
afterEach(() =>
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
viewportEl.remove()
|
|
83
|
+
mainEl.remove()
|
|
84
|
+
})
|
|
79
85
|
|
|
80
86
|
const renderViewport = () => {
|
|
81
|
-
const { container, rerender, unmount } = render(<Viewport
|
|
87
|
+
const { container, rerender, unmount } = render(<Viewport />)
|
|
82
88
|
const viewport = container.querySelector('.im-c-viewport')
|
|
83
89
|
const mapContainer = container.querySelector('.im-c-viewport__map-container')
|
|
84
90
|
const safeZone = container.querySelector('.im-c-viewport__safezone')
|
|
85
|
-
const keyboardHint =
|
|
91
|
+
const keyboardHint = mainEl.querySelector('.im-c-viewport__keyboard-hint')
|
|
86
92
|
const crossHair = container.querySelector('[data-testid="cross-hair"]')
|
|
87
93
|
const markers = container.querySelector('[data-testid="markers"]')
|
|
88
94
|
return { viewport, mapContainer, safeZone, keyboardHint, crossHair, markers, rerender, unmount }
|
|
@@ -109,14 +115,6 @@ describe('Viewport', () => {
|
|
|
109
115
|
expect(keyboardHint.innerHTML).toBe('Press arrow keys')
|
|
110
116
|
})
|
|
111
117
|
|
|
112
|
-
it('handles focus and blur events updating keyboard hint visibility', () => {
|
|
113
|
-
const { viewport, keyboardHint } = renderViewport()
|
|
114
|
-
fireEvent.focus(viewport)
|
|
115
|
-
fireEvent.blur(viewport)
|
|
116
|
-
expect(keyboardHint).toBeInTheDocument()
|
|
117
|
-
expect(keyboardHint.innerHTML).toBe('Press arrow keys')
|
|
118
|
-
})
|
|
119
|
-
|
|
120
118
|
it('attaches keyboard shortcuts', () => {
|
|
121
119
|
renderViewport()
|
|
122
120
|
expect(useKeyboardShortcuts).toHaveBeenCalled()
|
|
@@ -137,25 +135,10 @@ describe('Viewport', () => {
|
|
|
137
135
|
interfaceType: 'desktop',
|
|
138
136
|
mode: 'edit',
|
|
139
137
|
previousMode: 'default',
|
|
140
|
-
layoutRefs: {
|
|
138
|
+
layoutRefs: { mainRef: { current: mainEl }, viewportRef: { current: viewport }, safeZoneRef: { current: null } },
|
|
141
139
|
safeZoneInset: {}
|
|
142
140
|
})
|
|
143
|
-
rerender(<Viewport
|
|
141
|
+
rerender(<Viewport />)
|
|
144
142
|
expect(focusMock).toHaveBeenCalled()
|
|
145
143
|
})
|
|
146
|
-
|
|
147
|
-
it('toggles main element class for keyboard hint and cleans up on unmount', () => {
|
|
148
|
-
const mainEl = document.createElement('div')
|
|
149
|
-
useApp.mockReturnValueOnce({
|
|
150
|
-
interfaceType: 'desktop',
|
|
151
|
-
mode: 'default',
|
|
152
|
-
previousMode: 'default',
|
|
153
|
-
layoutRefs: { viewportRef: { current: null }, mainRef: { current: mainEl }, safeZoneRef: { current: null } },
|
|
154
|
-
safeZoneInset: {}
|
|
155
|
-
})
|
|
156
|
-
const { unmount } = renderViewport()
|
|
157
|
-
expect(mainEl.classList.contains('im-o-app__main--keyboard-hint-visible')).toBe(true)
|
|
158
|
-
unmount()
|
|
159
|
-
expect(mainEl.classList.contains('im-o-app__main--keyboard-hint-visible')).toBe(false)
|
|
160
|
-
})
|
|
161
144
|
})
|
|
@@ -54,16 +54,16 @@ export const useInterfaceAPI = () => {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
const
|
|
58
|
-
const
|
|
57
|
+
const handleAppOpened = () => dispatchRef.current({ type: 'TOGGLE_APP_VISIBLE', payload: true })
|
|
58
|
+
const handleAppClosed = () => dispatchRef.current({ type: 'TOGGLE_APP_VISIBLE', payload: false })
|
|
59
59
|
const handleAddPanel = ({ id, config }) => dispatchRef.current({ type: 'ADD_PANEL', payload: { id, config } })
|
|
60
60
|
const handleRemovePanel = (id) => dispatchRef.current({ type: 'REMOVE_PANEL', payload: id })
|
|
61
|
-
const handleShowPanel = (id) => dispatchRef.current({ type: 'OPEN_PANEL', payload: { panelId: id } })
|
|
61
|
+
const handleShowPanel = ({ id, focus = true }) => dispatchRef.current({ type: 'OPEN_PANEL', payload: { panelId: id, focusOnOpen: focus } })
|
|
62
62
|
const handleHidePanel = (id) => dispatchRef.current({ type: 'CLOSE_PANEL', payload: id })
|
|
63
63
|
const handleAddControl = ({ id, config }) => dispatchRef.current({ type: 'ADD_CONTROL', payload: { id, config } })
|
|
64
64
|
|
|
65
|
-
eventBus.on(events.
|
|
66
|
-
eventBus.on(events.
|
|
65
|
+
eventBus.on(events.APP_OPENED, handleAppOpened)
|
|
66
|
+
eventBus.on(events.APP_CLOSED, handleAppClosed)
|
|
67
67
|
eventBus.on(events.APP_ADD_BUTTON, handleAddButton)
|
|
68
68
|
eventBus.on(events.APP_TOGGLE_BUTTON_STATE, handleToggleButtonState)
|
|
69
69
|
eventBus.on(events.APP_ADD_PANEL, handleAddPanel)
|
|
@@ -73,8 +73,8 @@ export const useInterfaceAPI = () => {
|
|
|
73
73
|
eventBus.on(events.APP_ADD_CONTROL, handleAddControl)
|
|
74
74
|
|
|
75
75
|
return () => {
|
|
76
|
-
eventBus.off(events.
|
|
77
|
-
eventBus.off(events.
|
|
76
|
+
eventBus.off(events.APP_OPENED, handleAppOpened)
|
|
77
|
+
eventBus.off(events.APP_CLOSED, handleAppClosed)
|
|
78
78
|
eventBus.off(events.APP_ADD_BUTTON, handleAddButton)
|
|
79
79
|
eventBus.off(events.APP_TOGGLE_BUTTON_STATE, handleToggleButtonState)
|
|
80
80
|
eventBus.off(events.APP_ADD_PANEL, handleAddPanel)
|
|
@@ -56,15 +56,15 @@ describe('useInterfaceAPI', () => {
|
|
|
56
56
|
expect(mockDispatch).toHaveBeenCalledWith({ type: 'ADD_BUTTON', payload: { id: 'item2', config: { id: 'item2', label: 'Item 2', isMenuItem: true } } })
|
|
57
57
|
})
|
|
58
58
|
|
|
59
|
-
it('dispatches TOGGLE_APP_VISIBLE true on app:
|
|
59
|
+
it('dispatches TOGGLE_APP_VISIBLE true on app:opened', () => {
|
|
60
60
|
renderHook(() => useInterfaceAPI())
|
|
61
|
-
act(() => mockEventBus.emit('app:
|
|
61
|
+
act(() => mockEventBus.emit('app:opened'))
|
|
62
62
|
expect(mockDispatch).toHaveBeenCalledWith({ type: 'TOGGLE_APP_VISIBLE', payload: true })
|
|
63
63
|
})
|
|
64
64
|
|
|
65
|
-
it('dispatches TOGGLE_APP_VISIBLE false on app:
|
|
65
|
+
it('dispatches TOGGLE_APP_VISIBLE false on app:closed', () => {
|
|
66
66
|
renderHook(() => useInterfaceAPI())
|
|
67
|
-
act(() => mockEventBus.emit('app:
|
|
67
|
+
act(() => mockEventBus.emit('app:closed'))
|
|
68
68
|
expect(mockDispatch).toHaveBeenCalledWith({ type: 'TOGGLE_APP_VISIBLE', payload: false })
|
|
69
69
|
})
|
|
70
70
|
|
|
@@ -80,10 +80,16 @@ describe('useInterfaceAPI', () => {
|
|
|
80
80
|
expect(mockDispatch).toHaveBeenCalledWith({ type: 'REMOVE_PANEL', payload: 'panel1' })
|
|
81
81
|
})
|
|
82
82
|
|
|
83
|
-
it('dispatches OPEN_PANEL on app:showpanel', () => {
|
|
83
|
+
it('dispatches OPEN_PANEL with focusOnOpen:true on app:showpanel by default', () => {
|
|
84
84
|
renderHook(() => useInterfaceAPI())
|
|
85
|
-
act(() => mockEventBus.emit('app:showpanel', 'panel1'))
|
|
86
|
-
expect(mockDispatch).toHaveBeenCalledWith({ type: 'OPEN_PANEL', payload: { panelId: 'panel1' } })
|
|
85
|
+
act(() => mockEventBus.emit('app:showpanel', { id: 'panel1' }))
|
|
86
|
+
expect(mockDispatch).toHaveBeenCalledWith({ type: 'OPEN_PANEL', payload: { panelId: 'panel1', focusOnOpen: true } })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('dispatches OPEN_PANEL with focusOnOpen:false when focus:false', () => {
|
|
90
|
+
renderHook(() => useInterfaceAPI())
|
|
91
|
+
act(() => mockEventBus.emit('app:showpanel', { id: 'panel1', focus: false }))
|
|
92
|
+
expect(mockDispatch).toHaveBeenCalledWith({ type: 'OPEN_PANEL', payload: { panelId: 'panel1', focusOnOpen: false } })
|
|
87
93
|
})
|
|
88
94
|
|
|
89
95
|
it('dispatches CLOSE_PANEL on app:hidepanel', () => {
|
|
@@ -143,8 +149,8 @@ describe('useInterfaceAPI', () => {
|
|
|
143
149
|
it('removes all event listeners on unmount', () => {
|
|
144
150
|
const { unmount } = renderHook(() => useInterfaceAPI())
|
|
145
151
|
unmount()
|
|
146
|
-
expect(mockEventBus.off).toHaveBeenCalledWith('app:
|
|
147
|
-
expect(mockEventBus.off).toHaveBeenCalledWith('app:
|
|
152
|
+
expect(mockEventBus.off).toHaveBeenCalledWith('app:opened', expect.any(Function))
|
|
153
|
+
expect(mockEventBus.off).toHaveBeenCalledWith('app:closed', expect.any(Function))
|
|
148
154
|
expect(mockEventBus.off).toHaveBeenCalledWith('app:addbutton', expect.any(Function))
|
|
149
155
|
expect(mockEventBus.off).toHaveBeenCalledWith('app:togglebuttonstate', expect.any(Function))
|
|
150
156
|
expect(mockEventBus.off).toHaveBeenCalledWith('app:addpanel', expect.any(Function))
|
|
@@ -51,80 +51,72 @@ const subSlotMaxHeight = (columnHeight, siblingButtons, gap) =>
|
|
|
51
51
|
* It does not dispatch the safe zone — safe zone dispatch is owned entirely by
|
|
52
52
|
* Effect 3 to prevent jumps on panel open/close and other non-structural resizes.
|
|
53
53
|
*/
|
|
54
|
-
|
|
55
|
-
const { dispatch, breakpoint, layoutRefs, arePluginsEvaluated, appVisible, isFullscreen } = useApp()
|
|
56
|
-
const { mapSize, isMapReady } = useMap()
|
|
57
|
-
|
|
54
|
+
function calculateLayout (layoutRefs) {
|
|
58
55
|
const {
|
|
59
|
-
appContainerRef,
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
topRef,
|
|
63
|
-
topLeftColRef,
|
|
64
|
-
topRightColRef,
|
|
65
|
-
leftTopRef,
|
|
66
|
-
leftBottomRef,
|
|
67
|
-
rightTopRef,
|
|
68
|
-
rightBottomRef,
|
|
69
|
-
bottomRef,
|
|
70
|
-
bottomRightRef,
|
|
71
|
-
attributionsRef,
|
|
72
|
-
drawerRef,
|
|
73
|
-
actionsRef
|
|
56
|
+
appContainerRef, mainRef, topRef, topLeftColRef, topRightColRef,
|
|
57
|
+
bottomRef, attributionsRef, bottomRightRef, leftTopRef, leftBottomRef,
|
|
58
|
+
rightTopRef, rightBottomRef
|
|
74
59
|
} = layoutRefs
|
|
75
60
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const topLeftCol = topLeftColRef.current
|
|
84
|
-
const topRightCol = topRightColRef.current
|
|
85
|
-
const bottom = bottomRef.current
|
|
86
|
-
const attributions = attributionsRef.current
|
|
87
|
-
|
|
88
|
-
if ([main, top, bottom].some(r => !r)) {
|
|
89
|
-
return
|
|
90
|
-
}
|
|
61
|
+
const appContainer = appContainerRef.current
|
|
62
|
+
const main = mainRef.current
|
|
63
|
+
const top = topRef.current
|
|
64
|
+
const topLeftCol = topLeftColRef.current
|
|
65
|
+
const topRightCol = topRightColRef.current
|
|
66
|
+
const bottom = bottomRef.current
|
|
67
|
+
const attributions = attributionsRef.current
|
|
91
68
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
// === Top column width ===
|
|
96
|
-
appContainer.style.setProperty('--top-col-width', `${topColWidth(topLeftCol.offsetWidth, topRightCol.offsetWidth)}px`)
|
|
97
|
-
|
|
98
|
-
// === Left container offsets ===
|
|
99
|
-
const leftOffsetTop = topLeftCol.offsetHeight + top.offsetTop
|
|
100
|
-
const leftColumnHeight = bottom.offsetTop - leftOffsetTop - dividerGap
|
|
101
|
-
appContainer.style.setProperty('--left-offset-top', `${leftOffsetTop}px`)
|
|
102
|
-
appContainer.style.setProperty('--left-offset-bottom', `${main.offsetHeight - bottom.offsetTop + dividerGap}px`)
|
|
103
|
-
appContainer.style.setProperty('--left-top-max-height', `${leftColumnHeight}px`)
|
|
104
|
-
|
|
105
|
-
// === Right container offsets ===
|
|
106
|
-
// Mirrors the top formula (topRightCol.offsetHeight + top.offsetTop):
|
|
107
|
-
// bottomRight.offsetHeight is 0 when no buttons so the offset collapses to just
|
|
108
|
-
// the padding between the bottom of the bottom container and the bottom of main.
|
|
109
|
-
const bottomRightHeight = bottomRightRef?.current?.offsetHeight ?? 0
|
|
110
|
-
const bottomContainerPad = main.offsetHeight - bottom.offsetTop - bottom.offsetHeight
|
|
111
|
-
const rightOffsetTop = topRightCol.offsetHeight + top.offsetTop
|
|
112
|
-
const rightEffectiveBottom = bottom.offsetTop + bottom.offsetHeight - bottomRightHeight
|
|
113
|
-
const rightColumnHeight = rightEffectiveBottom - rightOffsetTop - dividerGap
|
|
114
|
-
const rightOffsetBottom = bottomContainerPad + (bottomRightHeight > 0 ? (bottomRightHeight + dividerGap) : attributions.offsetHeight)
|
|
115
|
-
appContainer.style.setProperty('--right-offset-top', `${rightOffsetTop}px`)
|
|
116
|
-
appContainer.style.setProperty('--right-offset-bottom', `${rightOffsetBottom}px`)
|
|
117
|
-
appContainer.style.setProperty('--right-top-max-height', `${rightColumnHeight}px`)
|
|
118
|
-
|
|
119
|
-
// === Sub-slot panel max-heights ===
|
|
120
|
-
appContainer.style.setProperty('--left-top-panel-max-height', `${subSlotMaxHeight(leftColumnHeight, buttonHeight(leftBottomRef), dividerGap)}px`)
|
|
121
|
-
appContainer.style.setProperty('--left-bottom-panel-max-height', `${subSlotMaxHeight(leftColumnHeight, buttonHeight(leftTopRef), dividerGap)}px`)
|
|
122
|
-
appContainer.style.setProperty('--right-top-panel-max-height', `${subSlotMaxHeight(rightColumnHeight, buttonHeight(rightBottomRef), dividerGap)}px`)
|
|
123
|
-
appContainer.style.setProperty('--right-bottom-panel-max-height', `${subSlotMaxHeight(rightColumnHeight, buttonHeight(rightTopRef), dividerGap)}px`)
|
|
69
|
+
if ([main, top, bottom].some(r => !r)) {
|
|
70
|
+
return
|
|
124
71
|
}
|
|
125
72
|
|
|
73
|
+
const root = document.documentElement
|
|
74
|
+
const dividerGap = Number.parseInt(getComputedStyle(root).getPropertyValue('--divider-gap'), 10)
|
|
75
|
+
|
|
76
|
+
// === Top column width ===
|
|
77
|
+
appContainer.style.setProperty('--top-col-width', `${topColWidth(topLeftCol.offsetWidth, topRightCol.offsetWidth)}px`)
|
|
78
|
+
|
|
79
|
+
// === Left container offsets ===
|
|
80
|
+
const leftOffsetTop = topLeftCol.offsetHeight + top.offsetTop
|
|
81
|
+
const leftColumnHeight = bottom.offsetTop - leftOffsetTop - dividerGap
|
|
82
|
+
appContainer.style.setProperty('--left-offset-top', `${leftOffsetTop}px`)
|
|
83
|
+
appContainer.style.setProperty('--left-offset-bottom', `${main.offsetHeight - bottom.offsetTop + dividerGap}px`)
|
|
84
|
+
appContainer.style.setProperty('--left-top-max-height', `${leftColumnHeight}px`)
|
|
85
|
+
|
|
86
|
+
// === Right container offsets ===
|
|
87
|
+
// Mirrors the top formula (topRightCol.offsetHeight + top.offsetTop):
|
|
88
|
+
// bottomRight.offsetHeight is 0 when no buttons so the offset collapses to just
|
|
89
|
+
// the padding between the bottom of the bottom container and the bottom of main.
|
|
90
|
+
const bottomRightHeight = bottomRightRef?.current?.offsetHeight ?? 0
|
|
91
|
+
const bottomContainerPad = main.offsetHeight - bottom.offsetTop - bottom.offsetHeight
|
|
92
|
+
const rightOffsetTop = topRightCol.offsetHeight + top.offsetTop
|
|
93
|
+
const rightEffectiveBottom = bottom.offsetTop + bottom.offsetHeight - bottomRightHeight
|
|
94
|
+
const rightColumnHeight = rightEffectiveBottom - rightOffsetTop - dividerGap
|
|
95
|
+
const rightOffsetBottom = bottomContainerPad + (bottomRightHeight > 0 ? (bottomRightHeight + dividerGap) : attributions.offsetHeight)
|
|
96
|
+
appContainer.style.setProperty('--right-offset-top', `${rightOffsetTop}px`)
|
|
97
|
+
appContainer.style.setProperty('--right-offset-bottom', `${rightOffsetBottom}px`)
|
|
98
|
+
appContainer.style.setProperty('--right-top-max-height', `${rightColumnHeight}px`)
|
|
99
|
+
|
|
100
|
+
// === Keyboard hint bottom offset ===
|
|
101
|
+
// Distance from the bottom of im-o-app__bottom to the bottom of im-o-app__main.
|
|
102
|
+
// Used to position the hint above the bottom bar (and above drawers on mobile).
|
|
103
|
+
appContainer.style.setProperty('--keyboard-hint-bottom', `${main.offsetHeight - bottom.offsetTop - bottom.offsetHeight}px`)
|
|
104
|
+
|
|
105
|
+
// === Sub-slot panel max-heights ===
|
|
106
|
+
appContainer.style.setProperty('--left-top-panel-max-height', `${subSlotMaxHeight(leftColumnHeight, buttonHeight(leftBottomRef), dividerGap)}px`)
|
|
107
|
+
appContainer.style.setProperty('--left-bottom-panel-max-height', `${subSlotMaxHeight(leftColumnHeight, buttonHeight(leftTopRef), dividerGap)}px`)
|
|
108
|
+
appContainer.style.setProperty('--right-top-panel-max-height', `${subSlotMaxHeight(rightColumnHeight, buttonHeight(rightBottomRef), dividerGap)}px`)
|
|
109
|
+
appContainer.style.setProperty('--right-bottom-panel-max-height', `${subSlotMaxHeight(rightColumnHeight, buttonHeight(rightTopRef), dividerGap)}px`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function useLayoutMeasurements () {
|
|
113
|
+
const { dispatch, breakpoint, layoutRefs, arePluginsEvaluated, appVisible, isFullscreen } = useApp()
|
|
114
|
+
const { mapSize, isMapReady } = useMap()
|
|
115
|
+
|
|
116
|
+
const { bannerRef, mainRef, topRef, topLeftColRef, topRightColRef, bottomRef, bottomRightRef, leftTopRef, leftBottomRef, rightTopRef, rightBottomRef, drawerRef, actionsRef } = layoutRefs
|
|
117
|
+
|
|
126
118
|
// --------------------------------
|
|
127
|
-
//
|
|
119
|
+
// 1. Clear the evaluated flag when structural inputs change so the safe zone
|
|
128
120
|
// is not dispatched until useButtonStateEvaluator has completed a full
|
|
129
121
|
// pass with the new app/map state and set PLUGINS_EVALUATED.
|
|
130
122
|
// --------------------------------
|
|
@@ -133,7 +125,7 @@ export function useLayoutMeasurements () {
|
|
|
133
125
|
}, [breakpoint, mapSize, isMapReady, appVisible, isFullscreen])
|
|
134
126
|
|
|
135
127
|
// --------------------------------
|
|
136
|
-
//
|
|
128
|
+
// 2. Once all plugin button props have been evaluated (arePluginsEvaluated),
|
|
137
129
|
// recalculate layout and dispatch the safe zone inset.
|
|
138
130
|
// RAF required to ensure browser layout is committed before measuring.
|
|
139
131
|
// --------------------------------
|
|
@@ -142,7 +134,7 @@ export function useLayoutMeasurements () {
|
|
|
142
134
|
return
|
|
143
135
|
}
|
|
144
136
|
requestAnimationFrame(() => {
|
|
145
|
-
calculateLayout()
|
|
137
|
+
calculateLayout(layoutRefs)
|
|
146
138
|
const safeZoneInset = getSafeZoneInset(layoutRefs)
|
|
147
139
|
if (safeZoneInset) {
|
|
148
140
|
dispatch({ type: 'SET_SAFE_ZONE_INSET', payload: { safeZoneInset } })
|
|
@@ -151,13 +143,13 @@ export function useLayoutMeasurements () {
|
|
|
151
143
|
}, [arePluginsEvaluated])
|
|
152
144
|
|
|
153
145
|
// --------------------------------
|
|
154
|
-
//
|
|
146
|
+
// 3. Recalculate CSS vars whenever observed elements resize (panels, banner,
|
|
155
147
|
// actions buttons, etc.). Safe zone is intentionally not dispatched here —
|
|
156
|
-
// that is Effect
|
|
148
|
+
// that is Effect 2's responsibility.
|
|
157
149
|
// --------------------------------
|
|
158
150
|
useResizeObserver([bannerRef, mainRef, topRef, topLeftColRef, topRightColRef, actionsRef, bottomRef, bottomRightRef, leftTopRef, leftBottomRef, rightTopRef, rightBottomRef, drawerRef], () => {
|
|
159
151
|
requestAnimationFrame(() => {
|
|
160
|
-
calculateLayout()
|
|
152
|
+
calculateLayout(layoutRefs)
|
|
161
153
|
})
|
|
162
154
|
})
|
|
163
155
|
}
|
|
@@ -36,7 +36,7 @@ export const Layout = () => {
|
|
|
36
36
|
style={{ backgroundColor: mapStyle?.backgroundColor || undefined, ...getMapThemeVars(mapStyle) }}
|
|
37
37
|
ref={layoutRefs.appContainerRef}
|
|
38
38
|
>
|
|
39
|
-
<Viewport
|
|
39
|
+
<Viewport />
|
|
40
40
|
<div className={`im-o-app__overlay${isLayoutReady ? '' : ' im-o-app__overlay--not-ready'}`}>
|
|
41
41
|
<div className='im-o-app__side' ref={layoutRefs.sideRef}>
|
|
42
42
|
<SlotRenderer slot={layoutSlots.SIDE} />
|
|
@@ -436,16 +436,9 @@
|
|
|
436
436
|
// Inline border
|
|
437
437
|
.im-o-app--inline {
|
|
438
438
|
border: var(--app-border-width) solid var(--app-border-color);
|
|
439
|
+
box-sizing: border-box;
|
|
439
440
|
}
|
|
440
441
|
|
|
441
|
-
// Hide containers when keyboard hint is visible
|
|
442
|
-
.im-o-app__main--keyboard-hint-visible {
|
|
443
|
-
.im-o-app__top-col,
|
|
444
|
-
.im-o-app__right,
|
|
445
|
-
.im-o-app__right-bottom {
|
|
446
|
-
opacity: 0;
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
442
|
|
|
450
443
|
// Avoid refresh jump if layout clacs are not ready
|
|
451
444
|
.im-o-app__overlay--not-ready {
|
|
@@ -39,11 +39,14 @@ export const getSlotRef = (slot, layoutRefs) => {
|
|
|
39
39
|
* (e.g. the banner slot swaps DOM nodes between mobile and desktop).
|
|
40
40
|
*/
|
|
41
41
|
export const useDomProjection = (wrapperRef, targetSlot, isVisible, layoutRefs, breakpoint) => {
|
|
42
|
+
const layoutRefsRef = useRef(layoutRefs)
|
|
43
|
+
layoutRefsRef.current = layoutRefs
|
|
44
|
+
|
|
42
45
|
useLayoutEffect(() => {
|
|
43
46
|
const wrapper = wrapperRef.current
|
|
44
47
|
|
|
45
48
|
if (isVisible) {
|
|
46
|
-
const slotRef = getSlotRef(targetSlot,
|
|
49
|
+
const slotRef = getSlotRef(targetSlot, layoutRefsRef.current)
|
|
47
50
|
if (slotRef?.current) {
|
|
48
51
|
const backdrop = slotRef.current.querySelector(':scope > .im-o-app__modal-backdrop')
|
|
49
52
|
if (backdrop) {
|
|
@@ -54,8 +57,8 @@ export const useDomProjection = (wrapperRef, targetSlot, isVisible, layoutRefs,
|
|
|
54
57
|
wrapper.style.display = ''
|
|
55
58
|
}
|
|
56
59
|
} else {
|
|
57
|
-
if (wrapper.parentElement ===
|
|
58
|
-
|
|
60
|
+
if (wrapper.parentElement === layoutRefsRef.current.modalRef?.current) {
|
|
61
|
+
layoutRefsRef.current.appContainerRef?.current?.appendChild(wrapper)
|
|
59
62
|
}
|
|
60
63
|
wrapper.style.display = 'none'
|
|
61
64
|
}
|
|
@@ -63,7 +66,7 @@ export const useDomProjection = (wrapperRef, targetSlot, isVisible, layoutRefs,
|
|
|
63
66
|
return () => {
|
|
64
67
|
wrapper.style.display = 'none'
|
|
65
68
|
}
|
|
66
|
-
}, [isVisible, targetSlot,
|
|
69
|
+
}, [isVisible, targetSlot, breakpoint, wrapperRef])
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
/**
|
|
@@ -71,7 +74,7 @@ export const useDomProjection = (wrapperRef, targetSlot, isVisible, layoutRefs,
|
|
|
71
74
|
* The Panel component stays mounted for the lifetime of the registration.
|
|
72
75
|
* DOM projection moves it between slots; CSS hides it when closed.
|
|
73
76
|
*/
|
|
74
|
-
const PersistentPanel = ({ panelId, config, isOpen, openPanelProps, allowedModalPanelId, appState }) => {
|
|
77
|
+
const PersistentPanel = ({ panelId, config, isOpen, openPanelProps, focusOnOpen, allowedModalPanelId, appState }) => {
|
|
75
78
|
const panelRootRef = useRef(null)
|
|
76
79
|
const { breakpoint, mode, isFullscreen, layoutRefs } = appState
|
|
77
80
|
|
|
@@ -108,6 +111,7 @@ const PersistentPanel = ({ panelId, config, isOpen, openPanelProps, allowedModal
|
|
|
108
111
|
panelId={panelId}
|
|
109
112
|
panelConfig={config}
|
|
110
113
|
props={openPanelProps}
|
|
114
|
+
focusOnOpen={focusOnOpen}
|
|
111
115
|
html={config.html}
|
|
112
116
|
label={config.label}
|
|
113
117
|
isOpen={isOpen}
|
|
@@ -186,6 +190,7 @@ export const HtmlElementHost = () => {
|
|
|
186
190
|
config={config}
|
|
187
191
|
isOpen={!!openPanels[panelId]}
|
|
188
192
|
openPanelProps={openPanels[panelId]?.props}
|
|
193
|
+
focusOnOpen={openPanels[panelId]?.focusOnOpen}
|
|
189
194
|
allowedModalPanelId={allowedModalPanelId}
|
|
190
195
|
appState={appState}
|
|
191
196
|
/>
|
|
@@ -46,7 +46,7 @@ export function mapPanels ({ slot, appState, evaluateProp }) {
|
|
|
46
46
|
})
|
|
47
47
|
const allowedModalPanelId = modalPanels.length > 0 ? modalPanels[modalPanels.length - 1][0] : null
|
|
48
48
|
|
|
49
|
-
return openPanelEntries.map(([panelId, { props }]) => {
|
|
49
|
+
return openPanelEntries.map(([panelId, { props, focusOnOpen }]) => {
|
|
50
50
|
const config = panelConfig[panelId]
|
|
51
51
|
if (!config) {
|
|
52
52
|
return null
|
|
@@ -91,6 +91,7 @@ export function mapPanels ({ slot, appState, evaluateProp }) {
|
|
|
91
91
|
panelId={panelId}
|
|
92
92
|
panelConfig={config}
|
|
93
93
|
props={props}
|
|
94
|
+
focusOnOpen={focusOnOpen}
|
|
94
95
|
WrappedChild={WrappedChild}
|
|
95
96
|
label={evaluateProp(config.label, pluginId)}
|
|
96
97
|
html={pluginId ? evaluateProp(config.html, pluginId) : config.html}
|
|
@@ -7,7 +7,7 @@ import { registerPanel as registerPanelFn, addPanel as addPanelFn, removePanel a
|
|
|
7
7
|
import { registerControl as registerControlFn, addControl as addControlFn } from '../registry/controlRegistry.js'
|
|
8
8
|
|
|
9
9
|
// Interal helper
|
|
10
|
-
function buildOpenPanels (state, panelId, breakpoint, props) {
|
|
10
|
+
function buildOpenPanels (state, panelId, breakpoint, props, focusOnOpen) {
|
|
11
11
|
const panelConfig = state.panelConfig || state.panelRegistry.getPanelConfig()
|
|
12
12
|
const bpConfig = panelConfig[panelId]?.[breakpoint]
|
|
13
13
|
const isExclusiveNonModal = !!bpConfig.exclusive && !bpConfig.modal
|
|
@@ -23,7 +23,7 @@ function buildOpenPanels (state, panelId, breakpoint, props) {
|
|
|
23
23
|
return {
|
|
24
24
|
...(isExclusiveNonModal ? {} : filteredPanels),
|
|
25
25
|
...(isModal ? state.openPanels : {}),
|
|
26
|
-
[panelId]: { props }
|
|
26
|
+
[panelId]: { props, ...(focusOnOpen && { focusOnOpen: true }) }
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
@@ -109,12 +109,12 @@ const setInterfaceType = (state, payload) => {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
const openPanel = (state, payload) => {
|
|
112
|
-
const { panelId, props = {} } = payload
|
|
112
|
+
const { panelId, props = {}, focusOnOpen } = payload
|
|
113
113
|
|
|
114
114
|
return {
|
|
115
115
|
...state,
|
|
116
116
|
previousOpenPanels: state.openPanels,
|
|
117
|
-
openPanels: buildOpenPanels(state, panelId, state.breakpoint, props)
|
|
117
|
+
openPanels: buildOpenPanels(state, panelId, state.breakpoint, props, focusOnOpen)
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
|
|
@@ -100,6 +100,16 @@ describe('actionsMap full coverage', () => {
|
|
|
100
100
|
expect(result.openPanels.panel3?.props).toEqual({})
|
|
101
101
|
})
|
|
102
102
|
|
|
103
|
+
test('OPEN_PANEL stores focusOnOpen when provided', () => {
|
|
104
|
+
const result = actionsMap.OPEN_PANEL(state, { panelId: 'panel2', focusOnOpen: true })
|
|
105
|
+
expect(result.openPanels.panel2?.focusOnOpen).toBe(true)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('OPEN_PANEL omits focusOnOpen when not provided', () => {
|
|
109
|
+
const result = actionsMap.OPEN_PANEL(state, { panelId: 'panel2' })
|
|
110
|
+
expect(result.openPanels.panel2?.focusOnOpen).toBeUndefined()
|
|
111
|
+
})
|
|
112
|
+
|
|
103
113
|
test('CLOSE_PANEL removes a panel', () => {
|
|
104
114
|
const result = actionsMap.CLOSE_PANEL(state, 'panel1')
|
|
105
115
|
expect(result.openPanels.panel1).toBeUndefined()
|
|
@@ -103,7 +103,9 @@ export default class InteractiveMap {
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
_handleButtonClick (e) {
|
|
106
|
-
|
|
106
|
+
if (this.config.manageHistoryState) {
|
|
107
|
+
history.pushState({ isBack: true }, '', e.currentTarget.getAttribute('href'))
|
|
108
|
+
}
|
|
107
109
|
if (this._isHidden) {
|
|
108
110
|
this.showApp()
|
|
109
111
|
} else {
|
|
@@ -132,11 +134,20 @@ export default class InteractiveMap {
|
|
|
132
134
|
this.removeApp()
|
|
133
135
|
}
|
|
134
136
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
137
|
+
if (!this.config.manageHistoryState) {
|
|
138
|
+
return
|
|
139
|
+
}
|
|
138
140
|
|
|
139
|
-
history
|
|
141
|
+
// If this history entry was pushed by the map's open button, go back so the
|
|
142
|
+
// ?mv= entry is preserved as a forward entry (browser forward re-opens the map).
|
|
143
|
+
// Otherwise (direct URL / bookmark), just strip the param in place.
|
|
144
|
+
if (history.state?.isBack) {
|
|
145
|
+
history.back()
|
|
146
|
+
} else {
|
|
147
|
+
const key = this.config.mapViewParamKey
|
|
148
|
+
const newUrl = this._removeMapParamFromUrl(location.href, key)
|
|
149
|
+
history.replaceState(history.state, '', newUrl)
|
|
150
|
+
}
|
|
140
151
|
}
|
|
141
152
|
|
|
142
153
|
/**
|
|
@@ -190,6 +201,7 @@ export default class InteractiveMap {
|
|
|
190
201
|
})
|
|
191
202
|
|
|
192
203
|
updateDOMState(this)
|
|
204
|
+
this.eventBus.emit(events.APP_OPENED, { statePreserved: false })
|
|
193
205
|
} catch (err) {
|
|
194
206
|
renderError(this.rootEl, this.config.genericErrorText)
|
|
195
207
|
console.error(err)
|
|
@@ -213,8 +225,9 @@ export default class InteractiveMap {
|
|
|
213
225
|
this._openButton.focus()
|
|
214
226
|
}
|
|
215
227
|
|
|
216
|
-
updateDOMState(this)
|
|
228
|
+
updateDOMState(this, { isFullscreen: false })
|
|
217
229
|
|
|
230
|
+
this.eventBus.emit(events.APP_CLOSED, { statePreserved: false })
|
|
218
231
|
this.eventBus.emit(events.MAP_DESTROY, { mapId: this.id })
|
|
219
232
|
}
|
|
220
233
|
|
|
@@ -243,10 +256,10 @@ export default class InteractiveMap {
|
|
|
243
256
|
// Reset page title (remove prepended map title)
|
|
244
257
|
const parts = document.title.split(': ')
|
|
245
258
|
if (parts.length > 1) {
|
|
246
|
-
document.title = parts
|
|
259
|
+
document.title = parts.at(-1)
|
|
247
260
|
}
|
|
248
261
|
|
|
249
|
-
this.eventBus.emit(events.
|
|
262
|
+
this.eventBus.emit(events.APP_CLOSED, { statePreserved: true })
|
|
250
263
|
}
|
|
251
264
|
|
|
252
265
|
/**
|
|
@@ -265,7 +278,7 @@ export default class InteractiveMap {
|
|
|
265
278
|
|
|
266
279
|
updateDOMState(this)
|
|
267
280
|
|
|
268
|
-
this.eventBus.emit(events.
|
|
281
|
+
this.eventBus.emit(events.APP_OPENED, { statePreserved: true })
|
|
269
282
|
}
|
|
270
283
|
|
|
271
284
|
/**
|
|
@@ -365,6 +378,10 @@ export default class InteractiveMap {
|
|
|
365
378
|
/**
|
|
366
379
|
* Add a panel to the UI.
|
|
367
380
|
*
|
|
381
|
+
* Focus is moved to the panel on open by default. Set `focus: false` in the
|
|
382
|
+
* config to suppress this — useful when adding panels on page load where
|
|
383
|
+
* stealing focus would be disruptive.
|
|
384
|
+
*
|
|
368
385
|
* @param {string} id - Unique panel identifier.
|
|
369
386
|
* @param {PanelDefinition} config - Panel configuration.
|
|
370
387
|
*/
|
|
@@ -384,10 +401,15 @@ export default class InteractiveMap {
|
|
|
384
401
|
/**
|
|
385
402
|
* Show a panel.
|
|
386
403
|
*
|
|
404
|
+
* Focus is moved to the panel by default. Set `focus: false` in options to
|
|
405
|
+
* suppress this — useful when showing a panel and you want focus to remain on the button.
|
|
406
|
+
*
|
|
387
407
|
* @param {string} id - Panel identifier to show.
|
|
408
|
+
* @param {object} [options]
|
|
409
|
+
* @param {boolean} [options.focus=true] - Whether to move focus to the panel.
|
|
388
410
|
*/
|
|
389
|
-
showPanel (id) {
|
|
390
|
-
this.eventBus.emit(events.APP_SHOW_PANEL, id)
|
|
411
|
+
showPanel (id, { focus = true } = {}) {
|
|
412
|
+
this.eventBus.emit(events.APP_SHOW_PANEL, { id, focus })
|
|
391
413
|
}
|
|
392
414
|
|
|
393
415
|
/**
|
|
@@ -426,4 +448,30 @@ export default class InteractiveMap {
|
|
|
426
448
|
setView (opts) {
|
|
427
449
|
this.eventBus.emit(events.MAP_SET_VIEW, opts)
|
|
428
450
|
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Programmatically open the map.
|
|
454
|
+
*
|
|
455
|
+
* Equivalent to the user clicking the open button. If the map has been hidden (e.g. in hybrid mode),
|
|
456
|
+
* it will be shown; otherwise the app will be loaded for the first time.
|
|
457
|
+
*/
|
|
458
|
+
open () {
|
|
459
|
+
if (this._isHidden) {
|
|
460
|
+
this.showApp()
|
|
461
|
+
} else if (this._root) {
|
|
462
|
+
// App is already open — no-op
|
|
463
|
+
} else {
|
|
464
|
+
this.loadApp()
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Programmatically close the map.
|
|
470
|
+
*
|
|
471
|
+
* Triggers the same logic as the exit button. If `preserveStateOnClose` is true, the map is hidden
|
|
472
|
+
* but not destroyed; otherwise the app is removed entirely.
|
|
473
|
+
*/
|
|
474
|
+
close () {
|
|
475
|
+
this._handleExitClick()
|
|
476
|
+
}
|
|
429
477
|
}
|