@defra/interactive-map 0.0.10-alpha → 0.0.11-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.
Files changed (99) hide show
  1. package/README.md +1 -1
  2. package/dist/css/index.css +1 -1
  3. package/dist/esm/im-core.js +1 -1
  4. package/dist/esm/im-shell.js +1 -1
  5. package/dist/umd/im-core.js +1 -1
  6. package/dist/umd/index.js +1 -1
  7. package/docs/api/button-definition.md +21 -3
  8. package/docs/api/panel-definition.md +10 -12
  9. package/docs/api.md +80 -7
  10. package/docs/demo.mdx +70 -0
  11. package/docs/index.md +0 -4
  12. package/docs/plugins/plugin-context.md +3 -3
  13. package/docs/plugins/plugin-manifest.md +1 -1
  14. package/docusaurus.config.cjs +55 -25
  15. package/package.json +12 -7
  16. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  17. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  18. package/plugins/beta/datasets/src/manifest.js +3 -3
  19. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  20. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  21. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  22. package/plugins/beta/map-styles/src/manifest.js +3 -3
  23. package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
  24. package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
  25. package/plugins/beta/use-location/src/manifest.js +7 -7
  26. package/plugins/search/dist/css/index.css +1 -1
  27. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  28. package/plugins/search/dist/esm/index.js +1 -1
  29. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  30. package/plugins/search/dist/umd/index.js +1 -1
  31. package/plugins/search/src/Search.jsx +9 -3
  32. package/plugins/search/src/Search.test.jsx +26 -6
  33. package/plugins/search/src/components/Form/Form.jsx +35 -7
  34. package/plugins/search/src/components/Form/Form.module.scss +27 -0
  35. package/plugins/search/src/components/Form/Form.test.jsx +99 -2
  36. package/plugins/search/src/components/SubmitButton/SubmitButton.jsx +28 -0
  37. package/plugins/search/src/components/SubmitButton/SubmitButton.module.scss +8 -0
  38. package/plugins/search/src/components/SubmitButton/SubmitButton.test.jsx +33 -0
  39. package/plugins/search/src/datasets.js +15 -11
  40. package/plugins/search/src/datasets.test.js +17 -2
  41. package/plugins/search/src/events/fetchSuggestions.js +1 -1
  42. package/plugins/search/src/index.js +1 -1
  43. package/plugins/search/src/index.test.js +4 -4
  44. package/plugins/search/src/reducer.js +9 -4
  45. package/plugins/search/src/reducer.test.js +12 -7
  46. package/plugins/search/src/search.scss +5 -1
  47. package/plugins/search/src/utils/parseOsNamesResults.js +18 -2
  48. package/plugins/search/src/utils/parseOsNamesResults.test.js +33 -15
  49. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  50. package/providers/beta/esri/src/appEvents.js +8 -2
  51. package/providers/beta/esri/src/esriProvider.js +6 -14
  52. package/providers/beta/esri/src/mapEvents.js +7 -1
  53. package/providers/beta/esri/src/utils/coords.js +33 -1
  54. package/providers/beta/esri/src/utils/coords.test.js +126 -0
  55. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  56. package/providers/maplibre/dist/esm/index.js +1 -1
  57. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  58. package/providers/maplibre/dist/umd/index.js +1 -1
  59. package/providers/maplibre/src/appEvents.js +10 -1
  60. package/providers/maplibre/src/appEvents.test.js +13 -4
  61. package/providers/maplibre/src/index.js +5 -13
  62. package/providers/maplibre/src/index.test.js +34 -15
  63. package/providers/maplibre/src/mapEvents.js +9 -1
  64. package/providers/maplibre/src/maplibreProvider.js +14 -15
  65. package/providers/maplibre/src/maplibreProvider.test.js +14 -1
  66. package/providers/maplibre/src/utils/spatial.js +11 -0
  67. package/providers/maplibre/src/utils/spatial.test.js +12 -0
  68. package/src/App/components/Actions/Actions.module.scss +5 -4
  69. package/src/App/components/MapButton/MapButton.jsx +4 -16
  70. package/src/App/components/MapButton/MapButton.module.scss +12 -12
  71. package/src/App/components/MapButton/MapButton.test.jsx +0 -9
  72. package/src/App/components/Panel/Panel.jsx +6 -6
  73. package/src/App/components/Panel/Panel.test.jsx +14 -15
  74. package/src/App/components/Viewport/MapController.jsx +2 -1
  75. package/src/App/hooks/useLayoutMeasurements.js +1 -1
  76. package/src/App/hooks/useLayoutMeasurements.test.js +1 -1
  77. package/src/App/hooks/useMapProviderOverrides.js +21 -1
  78. package/src/App/hooks/useMapProviderOverrides.test.js +51 -2
  79. package/src/App/layout/Layout.jsx +4 -4
  80. package/src/App/layout/layout.module.scss +1 -0
  81. package/src/App/registry/panelRegistry.js +1 -10
  82. package/src/App/registry/panelRegistry.test.js +6 -11
  83. package/src/App/renderer/HtmlElementHost.jsx +11 -3
  84. package/src/App/renderer/HtmlElementHost.test.jsx +89 -0
  85. package/src/App/renderer/mapButtons.js +128 -28
  86. package/src/App/renderer/mapButtons.test.js +119 -19
  87. package/src/App/store/MapProvider.jsx +18 -5
  88. package/src/App/store/MapProvider.test.jsx +56 -1
  89. package/src/App/store/appActionsMap.js +17 -9
  90. package/src/App/store/appActionsMap.test.js +33 -7
  91. package/src/App/store/mapActionsMap.js +4 -7
  92. package/src/InteractiveMap/InteractiveMap.js +18 -0
  93. package/src/InteractiveMap/InteractiveMap.test.js +12 -0
  94. package/src/config/appConfig.js +17 -15
  95. package/src/config/events.js +41 -4
  96. package/src/config/getInitialOpenPanels.js +2 -2
  97. package/src/config/getInitialOpenPanels.test.js +7 -7
  98. package/src/types.js +13 -11
  99. package/src/utils/getValueForStyle.js +1 -1
@@ -11,12 +11,17 @@
11
11
  padding-top var(--duration) ease, padding-bottom var(--duration) ease;
12
12
  }
13
13
 
14
+ &--border-top {
15
+ border-top: 1px solid var(--app-border-color);
16
+ }
17
+
14
18
  &--hidden {
15
19
  max-height: 0;
16
20
  overflow: hidden;
17
21
  opacity: 0;
18
22
  padding-top: 0;
19
23
  padding-bottom: 0;
24
+ border: 0;
20
25
  }
21
26
 
22
27
  .im-c-button-wrapper--wide {
@@ -30,10 +35,6 @@
30
35
  }
31
36
  }
32
37
 
33
- .im-c-actions--border-top {
34
- border-top: 1px solid var(--app-border-color);
35
- }
36
-
37
38
  .im-o-app--tablet, .im-o-app--desktop {
38
39
  .im-c-actions {
39
40
  min-width: 0;
@@ -26,18 +26,12 @@ const buildButtonClassNames = (buttonId, variant, showLabel) => [
26
26
  * Builds CSS class names for the wrapper div that contains the button.
27
27
  * @param {string} buttonId - Unique identifier for the button
28
28
  * @param {boolean} showLabel - Whether the button label is displayed
29
- * @param {boolean} groupStart - Whether this button is at the start of a button group
30
- * @param {boolean} groupMiddle - Whether this button is in the middle of a button group
31
- * @param {boolean} groupEnd - Whether this button is at the end of a button group
32
29
  * @returns {string} Space-separated CSS class names for the wrapper
33
30
  */
34
- const buildWrapperClassNames = (buttonId, showLabel, groupStart, groupMiddle, groupEnd) => [
31
+ const buildWrapperClassNames = (buttonId, showLabel) => [
35
32
  'im-c-button-wrapper',
36
33
  buttonId && `im-c-button-wrapper--${stringToKebab(buttonId)}`,
37
- showLabel && 'im-c-button-wrapper--wide',
38
- groupStart && 'im-c-button-wrapper--group-start',
39
- groupMiddle && 'im-c-button-wrapper--group-middle',
40
- groupEnd && 'im-c-button-wrapper--group-end'
34
+ showLabel && 'im-c-button-wrapper--wide'
41
35
  ].filter(Boolean).join(' ')
42
36
 
43
37
  /**
@@ -159,9 +153,6 @@ const buildButtonProps = ({
159
153
  * @param {Array<Object>} [props.menuItems] - Array of items for popup menu
160
154
  * @param {string} [props.idPrefix=''] - Prefix for generated panel/popup IDs
161
155
  * @param {string} [props.href] - URL for anchor element; if provided, renders as <a> instead of <button>
162
- * @param {boolean} [props.groupStart=false] - Whether button is at start of button group
163
- * @param {boolean} [props.groupMiddle=false] - Whether button is in middle of button group
164
- * @param {boolean} [props.groupEnd=false] - Whether button is at end of button group
165
156
  * @returns {JSX.Element} The rendered button component
166
157
  */
167
158
  export const MapButton = ({
@@ -180,10 +171,7 @@ export const MapButton = ({
180
171
  panelId,
181
172
  menuItems,
182
173
  idPrefix,
183
- href,
184
- groupMiddle,
185
- groupStart,
186
- groupEnd
174
+ href
187
175
  }) => {
188
176
  const { id: appId } = useConfig()
189
177
  const { buttonRefs } = useApp()
@@ -255,7 +243,7 @@ export const MapButton = ({
255
243
 
256
244
  return (
257
245
  <div
258
- className={buildWrapperClassNames(buttonId, showLabel, groupStart, groupMiddle, groupEnd)}
246
+ className={buildWrapperClassNames(buttonId, showLabel)}
259
247
  style={isHidden ? { display: 'none' } : undefined}
260
248
  >
261
249
  {showLabel ? buttonEl : <Tooltip content={label}>{buttonEl}</Tooltip>}
@@ -138,16 +138,16 @@
138
138
  }
139
139
 
140
140
  .im-o-app__right {
141
- .im-c-button-wrapper--group-start,
142
- .im-c-button-wrapper--group-middle {
143
- margin-bottom: calc(-1 * var(--divider-gap));
141
+ .im-c-button-group {
142
+ display: flex;
143
+ flex-direction: column;
144
144
  }
145
145
 
146
- .im-c-button-wrapper--group-start .im-c-map-button {
146
+ .im-c-button-group .im-c-button-wrapper:first-child .im-c-map-button {
147
147
  @include tools.border-focus-corner-override($corners: 'top');
148
148
  }
149
149
 
150
- .im-c-button-wrapper--group-middle .im-c-map-button {
150
+ .im-c-button-group .im-c-button-wrapper:not(:first-child):not(:last-child) .im-c-map-button {
151
151
  @include tools.border-focus-corner-override($corners: 'none');
152
152
 
153
153
  &::before {
@@ -155,23 +155,23 @@
155
155
  }
156
156
  }
157
157
 
158
- .im-c-button-wrapper--group-end .im-c-map-button {
158
+ .im-c-button-group .im-c-button-wrapper:last-child .im-c-map-button {
159
159
  margin-top: 0;
160
160
  @include tools.border-focus-corner-override($corners: 'bottom');
161
161
  }
162
162
  }
163
163
 
164
164
  .im-o-app__top {
165
- .im-c-button-wrapper--group-start,
166
- .im-c-button-wrapper--group-middle {
167
- margin-right: calc(-1 * var(--divider-gap));
165
+ .im-c-button-group {
166
+ display: flex;
167
+ flex-direction: row;
168
168
  }
169
169
 
170
- .im-c-button-wrapper--group-start .im-c-map-button {
170
+ .im-c-button-group .im-c-button-wrapper:first-child .im-c-map-button {
171
171
  @include tools.border-focus-corner-override($corners: 'left');
172
172
  }
173
173
 
174
- .im-c-button-wrapper--group-middle .im-c-map-button {
174
+ .im-c-button-group .im-c-button-wrapper:not(:first-child):not(:last-child) .im-c-map-button {
175
175
  @include tools.border-focus-corner-override($corners: 'none');
176
176
 
177
177
  &::before {
@@ -179,7 +179,7 @@
179
179
  }
180
180
  }
181
181
 
182
- .im-c-button-wrapper--group-end .im-c-map-button {
182
+ .im-c-button-group .im-c-button-wrapper:last-child .im-c-map-button {
183
183
  @include tools.border-focus-corner-override($corners: 'right');
184
184
  }
185
185
  }
@@ -66,15 +66,6 @@ describe('MapButton', () => {
66
66
  expect(container.firstChild).toHaveStyle('display: none')
67
67
  })
68
68
 
69
- it.each([
70
- ['groupStart', 'im-c-button-wrapper--group-start'],
71
- ['groupMiddle', 'im-c-button-wrapper--group-middle'],
72
- ['groupEnd', 'im-c-button-wrapper--group-end']
73
- ])('applies wrapper %s class', (prop, className) => {
74
- const { container } = renderButton({ [prop]: true })
75
- expect(container.firstChild).toHaveClass(className)
76
- })
77
-
78
69
  it('handles panelId aria attributes', () => {
79
70
  renderButton({ panelId: 'Settings', idPrefix: 'prefix', isDisabled: true, isPanelOpen: false })
80
71
  const button = getButton()
@@ -7,10 +7,10 @@ import { useIsScrollable } from '../../hooks/useIsScrollable.js'
7
7
  import { Icon } from '../Icon/Icon'
8
8
 
9
9
  const computePanelState = (bpConfig, triggeringElement) => {
10
- const isAside = bpConfig.slot === 'side' && bpConfig.initiallyOpen && !bpConfig.modal
11
- const isDialog = !isAside && bpConfig.dismissable
10
+ const isAside = bpConfig.slot === 'side' && bpConfig.open && !bpConfig.modal
11
+ const isDialog = !isAside && bpConfig.dismissible
12
12
  const isModal = bpConfig.modal === true
13
- const isDismissable = bpConfig.dismissable !== false
13
+ const isDismissable = bpConfig.dismissible !== false
14
14
  const shouldFocus = Boolean(isModal || triggeringElement)
15
15
  const buttonContainerEl = bpConfig.slot.endsWith('button') ? triggeringElement?.parentNode : undefined
16
16
  return { isAside, isDialog, isModal, isDismissable, shouldFocus, buttonContainerEl }
@@ -96,8 +96,8 @@ export const Panel = ({ panelId, panelConfig, props, WrappedChild, label, html,
96
96
  }
97
97
  }, [isOpen])
98
98
 
99
- const panelClass = buildPanelClassNames(bpConfig.slot, panelConfig.showLabel)
100
- const panelBodyClass = buildPanelBodyClassNames(panelConfig.showLabel, isDismissable)
99
+ const panelClass = buildPanelClassNames(bpConfig.slot, bpConfig.showLabel ?? true)
100
+ const panelBodyClass = buildPanelBodyClassNames(bpConfig.showLabel ?? true, isDismissable)
101
101
  const innerHtmlProp = useMemo(() => html ? { __html: html } : null, [html])
102
102
 
103
103
  const panelProps = buildPanelProps({ elementId, shouldFocus, isDialog, isDismissable, isModal, width: bpConfig.width, panelClass })
@@ -110,7 +110,7 @@ export const Panel = ({ panelId, panelConfig, props, WrappedChild, label, html,
110
110
  >
111
111
  <h2
112
112
  id={`${elementId}-label`}
113
- className={panelConfig.showLabel ? 'im-c-panel__heading im-e-heading-m' : 'im-u-visually-hidden'}
113
+ className={(bpConfig.showLabel ?? true) ? 'im-c-panel__heading im-e-heading-m' : 'im-u-visually-hidden'}
114
114
  >
115
115
  {label}
116
116
  </h2>
@@ -32,8 +32,7 @@ describe('Panel', () => {
32
32
 
33
33
  const renderPanel = (config = {}, props = {}) => {
34
34
  const panelConfig = {
35
- showLabel: true,
36
- desktop: { slot: 'side', initiallyOpen: true, dismissable: false, modal: false },
35
+ desktop: { slot: 'side', open: true, dismissible: false, modal: false, showLabel: true },
37
36
  ...config
38
37
  }
39
38
  return render(<Panel panelId='Settings' panelConfig={panelConfig} label='Settings' {...props} />)
@@ -49,17 +48,17 @@ describe('Panel', () => {
49
48
  })
50
49
 
51
50
  it('renders visually hidden label when showLabel=false', () => {
52
- renderPanel({ showLabel: false })
51
+ renderPanel({ desktop: { slot: 'side', open: true, dismissible: false, modal: false, showLabel: false } })
53
52
  expect(screen.getByText('Settings')).toHaveClass('im-u-visually-hidden')
54
53
  })
55
54
 
56
- it('applies offset class to body when showLabel=false and dismissable', () => {
57
- renderPanel({ showLabel: false, desktop: { slot: 'side', dismissable: true, initiallyOpen: false } })
55
+ it('applies offset class to body when showLabel=false and dismissible', () => {
56
+ renderPanel({ desktop: { slot: 'side', dismissible: true, open: false, showLabel: false } })
58
57
  expect(screen.getByRole('dialog').querySelector('.im-c-panel__body')).toHaveClass('im-c-panel__body--offset')
59
58
  })
60
59
 
61
60
  it('applies width style if provided', () => {
62
- renderPanel({ desktop: { slot: 'side', dismissable: true, initiallyOpen: true, width: '300px' } })
61
+ renderPanel({ desktop: { slot: 'side', dismissible: true, open: true, width: '300px' } })
63
62
  expect(screen.getByRole('complementary')).toHaveStyle({ width: '300px' })
64
63
  })
65
64
 
@@ -82,23 +81,23 @@ describe('Panel', () => {
82
81
  })
83
82
 
84
83
  describe('role and aria attributes', () => {
85
- it('renders region role for non-dismissable panels', () => {
84
+ it('renders region role for non-dismissible panels', () => {
86
85
  renderPanel()
87
86
  expect(screen.getByRole('region')).toBeInTheDocument()
88
87
  })
89
88
 
90
- it('renders dialog role for dismissable non-aside panels', () => {
91
- renderPanel({ desktop: { slot: 'side', dismissable: true, initiallyOpen: false } })
89
+ it('renders dialog role for dismissible non-aside panels', () => {
90
+ renderPanel({ desktop: { slot: 'side', dismissible: true, open: false } })
92
91
  expect(screen.getByRole('dialog')).toBeInTheDocument()
93
92
  })
94
93
 
95
- it('renders complementary role for dismissable aside panels', () => {
96
- renderPanel({ desktop: { slot: 'side', initiallyOpen: true, dismissable: true } })
94
+ it('renders complementary role for dismissible aside panels', () => {
95
+ renderPanel({ desktop: { slot: 'side', open: true, dismissible: true } })
97
96
  expect(screen.getByRole('complementary')).toBeInTheDocument()
98
97
  })
99
98
 
100
99
  it('sets aria-modal and tabIndex for modal dialogs', () => {
101
- renderPanel({ desktop: { slot: 'overlay', dismissable: true, modal: true } })
100
+ renderPanel({ desktop: { slot: 'overlay', dismissible: true, modal: true } })
102
101
  const dialog = screen.getByRole('dialog')
103
102
  expect(dialog).toHaveAttribute('aria-modal', 'true')
104
103
  expect(dialog).toHaveAttribute('tabIndex', '-1')
@@ -111,7 +110,7 @@ describe('Panel', () => {
111
110
  const triggeringElement = { focus: focusMock, parentNode: document.createElement('div') }
112
111
 
113
112
  renderPanel(
114
- { desktop: { slot: 'top-button', dismissable: true, initiallyOpen: false } },
113
+ { desktop: { slot: 'top-button', dismissible: true, open: false } },
115
114
  { props: { triggeringElement } }
116
115
  )
117
116
 
@@ -125,7 +124,7 @@ describe('Panel', () => {
125
124
  const triggeringElement = { focus: focusMock, parentNode: document.createElement('div') }
126
125
 
127
126
  renderPanel(
128
- { desktop: { slot: 'overlay', dismissable: true, modal: true } },
127
+ { desktop: { slot: 'overlay', dismissible: true, modal: true } },
129
128
  { props: { triggeringElement } }
130
129
  )
131
130
 
@@ -134,7 +133,7 @@ describe('Panel', () => {
134
133
  })
135
134
 
136
135
  it('falls back to viewportRef focus when no triggeringElement', () => {
137
- renderPanel({ desktop: { slot: 'side', dismissable: true, initiallyOpen: false } })
136
+ renderPanel({ desktop: { slot: 'side', dismissible: true, open: false } })
138
137
 
139
138
  fireEvent.click(screen.getByRole('button', { name: 'Close Settings' }))
140
139
  expect(layoutRefs.viewportRef.current.focus).toHaveBeenCalled()
@@ -38,7 +38,8 @@ export const MapController = ({ mapContainerRef }) => {
38
38
  center: initialState.center,
39
39
  zoom: initialState.zoom,
40
40
  bounds: initialState.bounds,
41
- mapStyle
41
+ mapStyle,
42
+ mapSize
42
43
  })
43
44
  })
44
45
 
@@ -81,7 +81,7 @@ export function useLayoutMeasurements () {
81
81
  // --------------------------------
82
82
  // 3. Recaluclate CSS vars when elements resize
83
83
  // --------------------------------
84
- useResizeObserver([bannerRef, mainRef, topRef, actionsRef, footerRef], () => {
84
+ useResizeObserver([bannerRef, mainRef, topRef, topLeftColRef, topRightColRef, actionsRef, footerRef], () => {
85
85
  requestAnimationFrame(() => {
86
86
  calculateLayout()
87
87
  })
@@ -114,7 +114,7 @@ describe('useLayoutMeasurements', () => {
114
114
  const { layoutRefs } = setup()
115
115
  renderHook(() => useLayoutMeasurements())
116
116
  expect(useResizeObserver).toHaveBeenCalledWith(
117
- [layoutRefs.bannerRef, layoutRefs.mainRef, layoutRefs.topRef, layoutRefs.actionsRef, layoutRefs.footerRef],
117
+ [layoutRefs.bannerRef, layoutRefs.mainRef, layoutRefs.topRef, layoutRefs.topLeftColRef, layoutRefs.topRightColRef, layoutRefs.actionsRef, layoutRefs.footerRef],
118
118
  expect.any(Function)
119
119
  )
120
120
  layoutRefs.appContainerRef.current.style.setProperty.mockClear()
@@ -2,12 +2,13 @@ import { useEffect, useRef } from 'react'
2
2
  import { useConfig } from '../store/configContext.js'
3
3
  import { useApp } from '../store/appContext.js'
4
4
  import { useMap } from '../store/mapContext.js'
5
+ import { EVENTS as events } from '../../config/events.js'
5
6
  import { getSafeZoneInset } from '../../utils/getSafeZoneInset.js'
6
7
  import { scalePoints } from '../../utils/scalePoints.js'
7
8
  import { scaleFactor } from '../../config/appConfig.js'
8
9
 
9
10
  export const useMapProviderOverrides = () => {
10
- const { mapProvider } = useConfig()
11
+ const { mapProvider, eventBus } = useConfig()
11
12
  const { dispatch: appDispatch, layoutRefs } = useApp()
12
13
  const { mapSize } = useMap()
13
14
 
@@ -67,4 +68,23 @@ export const useMapProviderOverrides = () => {
67
68
  mapProvider.setView = originalSetView
68
69
  }
69
70
  }, [mapProvider, appDispatch, layoutRefs, mapSize])
71
+
72
+ // Forward public API events to the (overridden) mapProvider methods so that
73
+ // interactiveMap.fitToBounds() and interactiveMap.setView() respect safe zone padding.
74
+ useEffect(() => {
75
+ if (!mapProvider || !eventBus) {
76
+ return undefined
77
+ }
78
+
79
+ const handleFitToBounds = (bbox) => mapProvider.fitToBounds(bbox)
80
+ const handleSetView = (opts) => mapProvider.setView(opts)
81
+
82
+ eventBus.on(events.MAP_FIT_TO_BOUNDS, handleFitToBounds)
83
+ eventBus.on(events.MAP_SET_VIEW, handleSetView)
84
+
85
+ return () => {
86
+ eventBus.off(events.MAP_FIT_TO_BOUNDS, handleFitToBounds)
87
+ eventBus.off(events.MAP_SET_VIEW, handleSetView)
88
+ }
89
+ }, [mapProvider, eventBus])
70
90
  }
@@ -22,15 +22,21 @@ const setup = (overrides = {}) => {
22
22
  setPadding: jest.fn(),
23
23
  ...overrides.mapProvider
24
24
  }
25
+ const capturedHandlers = {}
26
+ const eventBus = {
27
+ on: jest.fn((event, handler) => { capturedHandlers[event] = handler }),
28
+ off: jest.fn(),
29
+ ...overrides.eventBus
30
+ }
25
31
 
26
- useConfig.mockReturnValue({ mapProvider, ...overrides.config })
32
+ useConfig.mockReturnValue({ mapProvider, eventBus, ...overrides.config })
27
33
  useApp.mockReturnValue({ dispatch, layoutRefs, ...overrides.app })
28
34
  useMap.mockReturnValue({ mapSize: 'md', ...overrides.map })
29
35
 
30
36
  getSafeZoneInset.mockReturnValue({ top: 10, right: 5, bottom: 15, left: 5 })
31
37
  scalePoints.mockReturnValue({ top: 20, right: 10, bottom: 30, left: 10 })
32
38
 
33
- return { dispatch, layoutRefs, mapProvider }
39
+ return { dispatch, layoutRefs, mapProvider, eventBus, capturedHandlers }
34
40
  }
35
41
 
36
42
  describe('useMapProviderOverrides', () => {
@@ -133,4 +139,47 @@ describe('useMapProviderOverrides', () => {
133
139
 
134
140
  expect(mapProvider.fitToBounds).not.toBe(firstOverride)
135
141
  })
142
+
143
+ test('subscribes to MAP_FIT_TO_BOUNDS and MAP_SET_VIEW on eventBus', () => {
144
+ const { eventBus } = setup()
145
+ renderHook(() => useMapProviderOverrides())
146
+
147
+ expect(eventBus.on).toHaveBeenCalledWith('map:fittobounds', expect.any(Function))
148
+ expect(eventBus.on).toHaveBeenCalledWith('map:setview', expect.any(Function))
149
+ })
150
+
151
+ test('MAP_FIT_TO_BOUNDS event forwards bbox to mapProvider.fitToBounds', () => {
152
+ const { mapProvider, capturedHandlers } = setup()
153
+ const originalFitToBounds = mapProvider.fitToBounds
154
+ renderHook(() => useMapProviderOverrides())
155
+
156
+ capturedHandlers['map:fittobounds']([0, 0, 1, 1])
157
+
158
+ expect(originalFitToBounds).toHaveBeenCalledWith([0, 0, 1, 1])
159
+ })
160
+
161
+ test('MAP_SET_VIEW event forwards opts to mapProvider.setView', () => {
162
+ const { mapProvider, capturedHandlers } = setup()
163
+ const originalSetView = mapProvider.setView
164
+ renderHook(() => useMapProviderOverrides())
165
+
166
+ capturedHandlers['map:setview']({ center: [1, 2], zoom: 10 })
167
+
168
+ expect(originalSetView).toHaveBeenCalledWith({ center: [1, 2], zoom: 10 })
169
+ })
170
+
171
+ test('unsubscribes from MAP_FIT_TO_BOUNDS and MAP_SET_VIEW on unmount', () => {
172
+ const { eventBus } = setup()
173
+ const { unmount } = renderHook(() => useMapProviderOverrides())
174
+
175
+ unmount()
176
+
177
+ expect(eventBus.off).toHaveBeenCalledWith('map:fittobounds', expect.any(Function))
178
+ expect(eventBus.off).toHaveBeenCalledWith('map:setview', expect.any(Function))
179
+ })
180
+
181
+ test('skips event subscriptions when eventBus is null', () => {
182
+ setup({ config: { eventBus: null } })
183
+ expect(() => renderHook(() => useMapProviderOverrides())).not.toThrow()
184
+ })
136
185
  })
@@ -94,13 +94,13 @@ export const Layout = () => {
94
94
  <div className='im-o-app__actions' ref={layoutRefs.actionsRef}>
95
95
  <SlotRenderer slot={layoutSlots.ACTIONS} />
96
96
  </div>
97
+ <div className='im-o-app__modal' ref={layoutRefs.modalRef}>
98
+ <SlotRenderer slot={layoutSlots.MODAL} />
99
+ <div className='im-o-app__modal-backdrop' />
100
+ </div>
97
101
  </div>
98
102
  </div>
99
103
  <HtmlElementHost />
100
- <div className='im-o-app__modal' ref={layoutRefs.modalRef}>
101
- <SlotRenderer slot={layoutSlots.MODAL} />
102
- <div className='im-o-app__modal-backdrop' />
103
- </div>
104
104
  </div>
105
105
  )
106
106
  }
@@ -261,6 +261,7 @@
261
261
  display: flex;
262
262
  position: absolute;
263
263
  top: 0;
264
+ left: 0;
264
265
  width: 100%;
265
266
  height: 100%;
266
267
  z-index: 99;
@@ -4,16 +4,7 @@ import { deepMerge } from '../../utils/deepMerge.js'
4
4
 
5
5
  // Pure utility functions for panel registry operations
6
6
  export const registerPanel = (currentConfig, panel) => {
7
- const normalizedPanelConfig = Object.fromEntries(
8
- Object.entries(panel).map(([key, value]) => [
9
- key,
10
- {
11
- showLabel: true,
12
- ...value
13
- }
14
- ])
15
- )
16
- return { ...currentConfig, ...normalizedPanelConfig }
7
+ return { ...currentConfig, ...panel }
17
8
  }
18
9
 
19
10
  export const addPanel = (currentConfig, id, config) => {
@@ -2,31 +2,28 @@ import { createPanelRegistry, registerPanel, addPanel, removePanel, getPanelConf
2
2
  import { defaultPanelConfig } from '../../config/appConfig.js'
3
3
 
4
4
  describe('panelRegistry', () => {
5
- test('registerPanel should store a panel with showLabel default', () => {
5
+ test('registerPanel should store a panel', () => {
6
6
  const panel = { settings: { title: 'Settings Panel' } }
7
7
  const config = registerPanel({}, panel)
8
8
  expect(config).toEqual({
9
9
  settings: {
10
- title: 'Settings Panel',
11
- showLabel: true
10
+ title: 'Settings Panel'
12
11
  }
13
12
  })
14
13
  })
15
14
 
16
15
  test('registerPanel should merge multiple panels', () => {
17
16
  const panel1 = { settings: { title: 'Settings Panel' } }
18
- const panel2 = { dashboard: { title: 'Dashboard Panel', showLabel: false } }
17
+ const panel2 = { dashboard: { title: 'Dashboard Panel' } }
19
18
  let config = {}
20
19
  config = registerPanel(config, panel1)
21
20
  config = registerPanel(config, panel2)
22
21
  expect(config).toEqual({
23
22
  settings: {
24
- title: 'Settings Panel',
25
- showLabel: true
23
+ title: 'Settings Panel'
26
24
  },
27
25
  dashboard: {
28
- title: 'Dashboard Panel',
29
- showLabel: false
26
+ title: 'Dashboard Panel'
30
27
  }
31
28
  })
32
29
  })
@@ -37,8 +34,7 @@ describe('panelRegistry', () => {
37
34
  const result = getPanelConfig(config)
38
35
  expect(result).toEqual({
39
36
  reports: {
40
- title: 'Reports Panel',
41
- showLabel: true
37
+ title: 'Reports Panel'
42
38
  }
43
39
  })
44
40
  })
@@ -100,7 +96,6 @@ describe('panelRegistry', () => {
100
96
  // Test registerPanel state
101
97
  registry.registerPanel({ p1: { title: 'P1' } })
102
98
  expect(registry.getPanelConfig()).toHaveProperty('p1')
103
- expect(registry.getPanelConfig().p1.showLabel).toBe(true)
104
99
 
105
100
  // Test addPanel state and return value
106
101
  const added = registry.addPanel('p2', { title: 'P2' })
@@ -1,5 +1,5 @@
1
1
  // src/App/renderer/HtmlElementHost.jsx
2
- import React, { useRef, useEffect, useMemo } from 'react'
2
+ import React, { useRef, useLayoutEffect, useMemo } from 'react'
3
3
  import { useApp } from '../store/appContext.js'
4
4
  import { Panel } from '../components/Panel/Panel.jsx'
5
5
  import { resolveTargetSlot, isModeAllowed, isControlVisible, isConsumerHtml } from './slotHelpers.js'
@@ -31,16 +31,24 @@ export const getSlotRef = (slot, layoutRefs) => {
31
31
  * (e.g. the banner slot swaps DOM nodes between mobile and desktop).
32
32
  */
33
33
  export const useDomProjection = (wrapperRef, targetSlot, isVisible, layoutRefs, breakpoint) => {
34
- useEffect(() => {
34
+ useLayoutEffect(() => {
35
35
  const wrapper = wrapperRef.current
36
36
 
37
37
  if (isVisible) {
38
38
  const slotRef = getSlotRef(targetSlot, layoutRefs)
39
39
  if (slotRef?.current) {
40
- slotRef.current.appendChild(wrapper)
40
+ const backdrop = slotRef.current.querySelector(':scope > .im-o-app__modal-backdrop')
41
+ if (backdrop) {
42
+ slotRef.current.insertBefore(wrapper, backdrop)
43
+ } else {
44
+ slotRef.current.appendChild(wrapper)
45
+ }
41
46
  wrapper.style.display = ''
42
47
  }
43
48
  } else {
49
+ if (wrapper.parentElement === layoutRefs.modalRef?.current) {
50
+ layoutRefs.appContainerRef?.current?.appendChild(wrapper)
51
+ }
44
52
  wrapper.style.display = 'none'
45
53
  }
46
54
 
@@ -199,6 +199,95 @@ describe('HtmlElementHost', () => {
199
199
  expect(container.firstChild).toBeNull() // still renders safely
200
200
  })
201
201
 
202
+ it('inserts panel before backdrop when modal slot contains a backdrop element', () => {
203
+ const modalRef = React.createRef()
204
+ const appContainerRef = React.createRef()
205
+ const refs = { ...layoutRefs, modalRef, appContainerRef }
206
+
207
+ mockApp({
208
+ panelConfig: { p1: { html: '<p>Hi</p>', label: 'Menu', desktop: { slot: 'side', modal: true } } },
209
+ openPanels: { p1: { props: {} } },
210
+ layoutRefs: refs
211
+ })
212
+
213
+ render(
214
+ <div>
215
+ <div ref={appContainerRef}>
216
+ <div ref={modalRef}>
217
+ <div className='im-o-app__modal-backdrop' />
218
+ </div>
219
+ </div>
220
+ <HtmlElementHost />
221
+ </div>
222
+ )
223
+
224
+ const children = Array.from(modalRef.current.children)
225
+ const panelIndex = children.findIndex(el => el.dataset.testid === 'panel-p1')
226
+ const backdropIndex = children.findIndex(el => el.classList.contains('im-o-app__modal-backdrop'))
227
+
228
+ expect(panelIndex).toBeGreaterThanOrEqual(0)
229
+ expect(panelIndex).toBeLessThan(backdropIndex)
230
+ })
231
+
232
+ it('moves panel from modal to appContainerRef when hidden', () => {
233
+ // Create refs
234
+ const modalRef = React.createRef()
235
+ const appContainerRef = React.createRef()
236
+ const refs = { ...layoutRefs, modalRef, appContainerRef }
237
+
238
+ // Panel config: modal slot
239
+ const panelConfig = {
240
+ p1: { html: '<p>Hi</p>', label: 'Menu', desktop: { slot: 'modal', modal: true } }
241
+ }
242
+
243
+ // App state: panel initially open
244
+ mockApp({
245
+ panelConfig,
246
+ openPanels: { p1: { props: {} } },
247
+ layoutRefs: refs
248
+ })
249
+
250
+ // Render a harness that includes both refs
251
+ const { rerender } = render(
252
+ <div>
253
+ <div ref={appContainerRef} />
254
+ <div ref={modalRef} />
255
+ <HtmlElementHost />
256
+ </div>
257
+ )
258
+
259
+ // Now it should actually be inside modalRef
260
+ const panelInModal = modalRef.current.querySelector('[data-testid="panel-p1"]')
261
+ expect(panelInModal).toBeTruthy()
262
+ expect(modalRef.current.contains(panelInModal)).toBe(true)
263
+
264
+ // Close the panel
265
+ useApp.mockReturnValue({
266
+ breakpoint: 'desktop',
267
+ mode: 'view',
268
+ isFullscreen: false,
269
+ panelConfig,
270
+ controlConfig: {},
271
+ openPanels: {}, // closed now
272
+ layoutRefs: refs,
273
+ dispatch: jest.fn()
274
+ })
275
+
276
+ rerender(
277
+ <div>
278
+ <div ref={appContainerRef} />
279
+ <div ref={modalRef} />
280
+ <HtmlElementHost />
281
+ </div>
282
+ )
283
+
284
+ // It should have moved to appContainerRef
285
+ const panelInApp = appContainerRef.current.querySelector('[data-testid="panel-p1"]')
286
+ expect(panelInApp).toBeTruthy()
287
+ expect(appContainerRef.current.contains(panelInApp)).toBe(true)
288
+ expect(modalRef.current.contains(panelInApp)).toBe(false)
289
+ })
290
+
202
291
  test('getSlotRef returns null for unknown slot', () => {
203
292
  expect(getSlotRef('unknown-slot', {})).toBeNull()
204
293
  })