@defra/interactive-map 0.0.10-alpha → 0.0.12-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 (121) 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-descriptor.md +37 -0
  14. package/docs/plugins/plugin-manifest.md +1 -1
  15. package/docusaurus.config.cjs +55 -25
  16. package/package.json +18 -9
  17. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  18. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  19. package/plugins/beta/datasets/src/manifest.js +3 -3
  20. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  21. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  22. package/plugins/beta/draw-ml/src/events.js +4 -14
  23. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +1 -3
  24. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  25. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  26. package/plugins/beta/map-styles/src/manifest.js +3 -3
  27. package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
  28. package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
  29. package/plugins/beta/use-location/src/manifest.js +7 -7
  30. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  31. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  32. package/plugins/interact/src/InteractInit.jsx +28 -6
  33. package/plugins/interact/src/InteractInit.test.js +19 -5
  34. package/plugins/interact/src/events.js +17 -15
  35. package/plugins/interact/src/events.test.js +25 -16
  36. package/plugins/search/dist/css/index.css +1 -1
  37. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  38. package/plugins/search/dist/esm/index.js +1 -1
  39. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  40. package/plugins/search/dist/umd/index.js +1 -1
  41. package/plugins/search/src/Search.jsx +9 -3
  42. package/plugins/search/src/Search.test.jsx +26 -6
  43. package/plugins/search/src/components/Form/Form.jsx +35 -7
  44. package/plugins/search/src/components/Form/Form.module.scss +27 -0
  45. package/plugins/search/src/components/Form/Form.test.jsx +99 -2
  46. package/plugins/search/src/components/SubmitButton/SubmitButton.jsx +28 -0
  47. package/plugins/search/src/components/SubmitButton/SubmitButton.module.scss +8 -0
  48. package/plugins/search/src/components/SubmitButton/SubmitButton.test.jsx +33 -0
  49. package/plugins/search/src/datasets.js +15 -11
  50. package/plugins/search/src/datasets.test.js +17 -2
  51. package/plugins/search/src/events/fetchSuggestions.js +1 -1
  52. package/plugins/search/src/index.js +1 -1
  53. package/plugins/search/src/index.test.js +4 -4
  54. package/plugins/search/src/reducer.js +9 -4
  55. package/plugins/search/src/reducer.test.js +12 -7
  56. package/plugins/search/src/search.scss +5 -1
  57. package/plugins/search/src/utils/parseOsNamesResults.js +18 -2
  58. package/plugins/search/src/utils/parseOsNamesResults.test.js +33 -15
  59. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  60. package/providers/beta/esri/src/appEvents.js +8 -2
  61. package/providers/beta/esri/src/esriProvider.js +25 -17
  62. package/providers/beta/esri/src/mapEvents.js +41 -4
  63. package/providers/beta/esri/src/utils/coords.js +34 -1
  64. package/providers/beta/esri/src/utils/coords.test.js +126 -0
  65. package/providers/beta/esri/src/utils/spatial.js +47 -1
  66. package/providers/beta/esri/src/utils/spatial.test.js +55 -0
  67. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  68. package/providers/maplibre/dist/esm/index.js +1 -1
  69. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  70. package/providers/maplibre/dist/umd/index.js +1 -1
  71. package/providers/maplibre/src/appEvents.js +10 -1
  72. package/providers/maplibre/src/appEvents.test.js +13 -4
  73. package/providers/maplibre/src/index.js +5 -13
  74. package/providers/maplibre/src/index.test.js +34 -15
  75. package/providers/maplibre/src/mapEvents.js +9 -1
  76. package/providers/maplibre/src/maplibreProvider.js +25 -15
  77. package/providers/maplibre/src/maplibreProvider.test.js +28 -2
  78. package/providers/maplibre/src/utils/spatial.js +51 -0
  79. package/providers/maplibre/src/utils/spatial.test.js +47 -0
  80. package/src/App/components/Actions/Actions.module.scss +5 -4
  81. package/src/App/components/MapButton/MapButton.jsx +4 -16
  82. package/src/App/components/MapButton/MapButton.module.scss +12 -12
  83. package/src/App/components/MapButton/MapButton.test.jsx +0 -9
  84. package/src/App/components/Panel/Panel.jsx +6 -6
  85. package/src/App/components/Panel/Panel.test.jsx +14 -15
  86. package/src/App/components/Viewport/MapController.jsx +6 -1
  87. package/src/App/hooks/useLayoutMeasurements.js +1 -1
  88. package/src/App/hooks/useLayoutMeasurements.test.js +1 -1
  89. package/src/App/hooks/useMapProviderOverrides.js +21 -1
  90. package/src/App/hooks/useMapProviderOverrides.test.js +51 -2
  91. package/src/App/hooks/useMarkersAPI.js +5 -3
  92. package/src/App/hooks/useModalPanelBehaviour.js +19 -2
  93. package/src/App/hooks/useModalPanelBehaviour.test.js +84 -60
  94. package/src/App/hooks/useVisibleGeometry.js +100 -0
  95. package/src/App/hooks/useVisibleGeometry.test.js +331 -0
  96. package/src/App/layout/Layout.jsx +5 -5
  97. package/src/App/layout/layout.module.scss +2 -4
  98. package/src/App/registry/panelRegistry.js +1 -10
  99. package/src/App/registry/panelRegistry.test.js +6 -11
  100. package/src/App/renderer/HtmlElementHost.jsx +12 -3
  101. package/src/App/renderer/HtmlElementHost.test.jsx +89 -0
  102. package/src/App/renderer/mapButtons.js +128 -28
  103. package/src/App/renderer/mapButtons.test.js +119 -19
  104. package/src/App/renderer/pluginWrapper.js +3 -2
  105. package/src/App/renderer/slots.js +1 -1
  106. package/src/App/store/AppProvider.jsx +1 -0
  107. package/src/App/store/MapProvider.jsx +18 -5
  108. package/src/App/store/MapProvider.test.jsx +56 -1
  109. package/src/App/store/appActionsMap.js +17 -9
  110. package/src/App/store/appActionsMap.test.js +33 -7
  111. package/src/App/store/appDispatchMiddleware.js +19 -0
  112. package/src/App/store/appDispatchMiddleware.test.js +56 -0
  113. package/src/App/store/mapActionsMap.js +4 -7
  114. package/src/InteractiveMap/InteractiveMap.js +18 -0
  115. package/src/InteractiveMap/InteractiveMap.test.js +12 -0
  116. package/src/config/appConfig.js +17 -15
  117. package/src/config/events.js +41 -4
  118. package/src/config/getInitialOpenPanels.js +2 -2
  119. package/src/config/getInitialOpenPanels.test.js +7 -7
  120. package/src/types.js +22 -11
  121. package/src/utils/getValueForStyle.js +1 -1
@@ -31,6 +31,15 @@ function getMatchingButtons ({ appState, buttonConfig, slot, evaluateProp }) {
31
31
  if (config.inline === false && !appState.isFullscreen) {
32
32
  return false
33
33
  }
34
+
35
+ // Skip panel-toggle buttons when the panel is non-dismissible (always visible) at this breakpoint
36
+ if (config.panelId) {
37
+ const panelBpConfig = appState.panelConfig?.[config.panelId]?.[breakpoint]
38
+ if (panelBpConfig?.open === true && panelBpConfig?.dismissible === false) {
39
+ return false
40
+ }
41
+ }
42
+
34
43
  if (bpConfig?.slot !== slot || !allowedSlots.button.includes(bpConfig.slot)) {
35
44
  return false
36
45
  }
@@ -61,7 +70,48 @@ function createButtonClickHandler (btn, appState, evaluateProp) {
61
70
  }
62
71
  }
63
72
 
64
- function renderButton ({ btn, appState, appConfig, evaluateProp, groupStart, groupMiddle, groupEnd }) {
73
+ /**
74
+ * Resolves the group name from a button config's group property.
75
+ * Accepts either the new object form `{ name, label?, order? }` or a deprecated plain string.
76
+ * @param {string|{name: string, label?: string, order?: number}|null|undefined} group
77
+ * @returns {string|null}
78
+ */
79
+ function resolveGroupName (group) {
80
+ if (group == null) {
81
+ return null
82
+ }
83
+ return typeof group === 'string' ? group : (group.name ?? null)
84
+ }
85
+
86
+ /**
87
+ * Resolves the accessible label for a group.
88
+ * Uses `label` if provided, otherwise falls back to `name`.
89
+ * @param {string|{name: string, label?: string, order?: number}|null|undefined} group
90
+ * @returns {string}
91
+ */
92
+ function resolveGroupLabel (group) {
93
+ if (!group) {
94
+ return ''
95
+ }
96
+ if (typeof group === 'string') {
97
+ return group
98
+ }
99
+ return group.label ?? group.name ?? ''
100
+ }
101
+
102
+ /**
103
+ * Resolves the slot-level order for a group.
104
+ * @param {string|{name: string, label?: string, order?: number}|null|undefined} group
105
+ * @returns {number}
106
+ */
107
+ function resolveGroupOrder (group) {
108
+ if (!group || typeof group === 'string') {
109
+ return 0
110
+ }
111
+ return group.order ?? 0
112
+ }
113
+
114
+ function renderButton ({ btn, appState, appConfig, evaluateProp }) {
65
115
  const [buttonId, config] = btn
66
116
  const bpConfig = config[appState.breakpoint] ?? {}
67
117
  const handleClick = createButtonClickHandler(btn, appState, evaluateProp)
@@ -76,7 +126,7 @@ function renderButton ({ btn, appState, appConfig, evaluateProp, groupStart, gro
76
126
  variant={config.variant}
77
127
  label={evaluateProp(config.label, config.pluginId)}
78
128
  href={evaluateProp(config.href, config.pluginId)}
79
- showLabel={bpConfig.showLabel}
129
+ showLabel={bpConfig.showLabel ?? true}
80
130
  isDisabled={appState.disabledButtons.has(buttonId)}
81
131
  isHidden={appState.hiddenButtons.has(buttonId)}
82
132
  isPressed={(config.isPressed !== undefined || config.pressedWhen) ? appState.pressedButtons.has(buttonId) : undefined}
@@ -86,9 +136,6 @@ function renderButton ({ btn, appState, appConfig, evaluateProp, groupStart, gro
86
136
  panelId={config.panelId}
87
137
  menuItems={config.menuItems}
88
138
  idPrefix={appConfig.id}
89
- groupStart={groupStart}
90
- groupMiddle={groupMiddle}
91
- groupEnd={groupEnd}
92
139
  />
93
140
  )
94
141
  }
@@ -102,44 +149,97 @@ function mapButtons ({ slot, appState, appConfig, evaluateProp }) {
102
149
  return []
103
150
  }
104
151
 
105
- const groupMap = new Map()
106
- matching.forEach(([, config], idx) => {
107
- const key = config.group
108
- if (key == null) {
152
+ // Partition matching buttons into named groups and ungrouped singletons
153
+ const groupMap = new Map() // name -> { label, order, members: [[buttonId, config]] }
154
+ const singletons = []
155
+
156
+ matching.forEach(([buttonId, config]) => {
157
+ const { group } = config
158
+
159
+ if (group == null) {
160
+ singletons.push([buttonId, config])
109
161
  return
110
162
  }
111
- if (!groupMap.has(key)) {
112
- groupMap.set(key, [])
163
+
164
+ /* istanbul ignore next */
165
+ if (process.env.NODE_ENV !== 'production' && typeof group === 'string') {
166
+ console.warn(`[interactive-map] Button "${buttonId}": group should be an object { name, label?, order? } — string groups are deprecated.`)
113
167
  }
114
- groupMap.get(key).push(idx)
115
- })
116
168
 
117
- for (const [key, indices] of groupMap) {
118
- if (indices.length < 2) {
119
- groupMap.delete(key)
169
+ const name = resolveGroupName(group)
170
+ const label = resolveGroupLabel(group)
171
+ const order = resolveGroupOrder(group)
172
+
173
+ if (groupMap.has(name)) {
174
+ const existing = groupMap.get(name)
175
+ /* istanbul ignore next */
176
+ if (process.env.NODE_ENV !== 'production' && existing.order !== order) {
177
+ console.warn(`[interactive-map] Group "${name}" has inconsistent order values (${existing.order} vs ${order}). Using the lower value.`)
178
+ existing.order = Math.min(existing.order, order)
179
+ }
180
+ } else {
181
+ groupMap.set(name, { label, order, members: [] })
120
182
  }
121
- }
122
183
 
123
- return matching.map((btn, idx) => {
184
+ groupMap.get(name).members.push([buttonId, config])
185
+ })
186
+
187
+ const result = []
188
+
189
+ // Ungrouped buttons — order is the breakpoint-level slot position
190
+ for (const btn of singletons) {
124
191
  const [buttonId, config] = btn
125
- const key = config.group
126
- const indices = key == null ? null : groupMap.get(key)
127
- const groupStart = indices ? idx === indices[0] : false
128
- const groupEnd = indices ? idx === indices[indices.length - 1] : false
129
- const groupMiddle = indices && indices.length >= 3 && !groupStart && !groupEnd // NOSONAR: 3 = minimum for a start/middle/end group
130
192
  const order = config[breakpoint]?.order ?? 0
131
-
132
- return {
193
+ result.push({
133
194
  id: buttonId,
134
195
  type: 'button',
135
196
  order,
136
- element: renderButton({ btn, appState, appConfig, evaluateProp, groupStart, groupMiddle, groupEnd })
197
+ element: renderButton({ btn, appState, appConfig, evaluateProp })
198
+ })
199
+ }
200
+
201
+ for (const [groupName, { label, order: groupOrder, members }] of groupMap) {
202
+ if (members.length < 2) {
203
+ // Singleton group: degrade to a regular button using the group's slot order
204
+ const btn = members[0]
205
+ const [buttonId, config] = btn
206
+ const order = groupOrder || config[breakpoint]?.order || 0
207
+ result.push({
208
+ id: buttonId,
209
+ type: 'button',
210
+ order,
211
+ element: renderButton({ btn, appState, appConfig, evaluateProp })
212
+ })
213
+ continue
137
214
  }
138
- })
215
+
216
+ // Sort group members by their intra-group order (breakpoint-level order prop)
217
+ const sorted = [...members].sort((a, b) => {
218
+ const orderA = a[1][breakpoint]?.order ?? 0
219
+ const orderB = b[1][breakpoint]?.order ?? 0
220
+ return orderA - orderB
221
+ })
222
+
223
+ result.push({
224
+ id: `group-${groupName}`,
225
+ type: 'group',
226
+ order: groupOrder,
227
+ element: (
228
+ <div key={`group-${groupName}`} role='group' aria-label={label} className='im-c-button-group'>{/* NOSONAR - div with role="group" is correct for a button group */}
229
+ {sorted.map(btn => renderButton({ btn, appState, appConfig, evaluateProp }))}
230
+ </div>
231
+ )
232
+ })
233
+ }
234
+
235
+ return result
139
236
  }
140
237
 
141
238
  export {
142
239
  mapButtons,
143
240
  getMatchingButtons,
144
- renderButton
241
+ renderButton,
242
+ resolveGroupName,
243
+ resolveGroupLabel,
244
+ resolveGroupOrder
145
245
  }
@@ -1,5 +1,5 @@
1
1
  import React from 'react'
2
- import { mapButtons, getMatchingButtons, renderButton } from './mapButtons.js'
2
+ import { mapButtons, getMatchingButtons, renderButton, resolveGroupName, resolveGroupLabel, resolveGroupOrder } from './mapButtons.js'
3
3
  import { getPanelConfig } from '../registry/panelRegistry.js'
4
4
 
5
5
  jest.mock('../registry/buttonRegistry.js')
@@ -44,6 +44,52 @@ describe('mapButtons module', () => {
44
44
  getPanelConfig.mockReturnValue({})
45
45
  })
46
46
 
47
+ // -------------------------
48
+ // resolveGroup* helper tests
49
+ // -------------------------
50
+ describe('resolveGroupName', () => {
51
+ it('returns null when group is null or undefined', () => {
52
+ expect(resolveGroupName(null)).toBeNull()
53
+ expect(resolveGroupName(undefined)).toBeNull()
54
+ })
55
+ it('returns the string when group is a string', () => {
56
+ expect(resolveGroupName('g1')).toBe('g1')
57
+ })
58
+ it('returns group.name when group is an object', () => {
59
+ expect(resolveGroupName({ name: 'g1' })).toBe('g1')
60
+ expect(resolveGroupName({ name: undefined })).toBeNull()
61
+ })
62
+ })
63
+
64
+ describe('resolveGroupLabel', () => {
65
+ it('returns empty string when group is falsy', () => {
66
+ expect(resolveGroupLabel(null)).toBe('')
67
+ expect(resolveGroupLabel(undefined)).toBe('')
68
+ })
69
+ it('returns the string itself when group is a string', () => {
70
+ expect(resolveGroupLabel('My Group')).toBe('My Group')
71
+ })
72
+ it('returns group.label when provided, else group.name, else empty string', () => {
73
+ expect(resolveGroupLabel({ name: 'g1', label: 'Group One' })).toBe('Group One')
74
+ expect(resolveGroupLabel({ name: 'g1' })).toBe('g1')
75
+ expect(resolveGroupLabel({ order: 5 })).toBe('')
76
+ })
77
+ })
78
+
79
+ describe('resolveGroupOrder', () => {
80
+ it('returns 0 when group is falsy', () => {
81
+ expect(resolveGroupOrder(null)).toBe(0)
82
+ expect(resolveGroupOrder(undefined)).toBe(0)
83
+ })
84
+ it('returns 0 when group is a string', () => {
85
+ expect(resolveGroupOrder('g1')).toBe(0)
86
+ })
87
+ it('returns group.order when provided, else 0', () => {
88
+ expect(resolveGroupOrder({ name: 'g1', order: 5 })).toBe(5)
89
+ expect(resolveGroupOrder({ name: 'g1' })).toBe(0)
90
+ })
91
+ })
92
+
47
93
  // -------------------------
48
94
  // getMatchingButtons tests
49
95
  // -------------------------
@@ -92,25 +138,32 @@ describe('mapButtons module', () => {
92
138
  const config = { b1: baseBtn, b2: { ...baseBtn, isMenuItem: false } }
93
139
  expect(getMatchingButtons({ buttonConfig: config, slot: 'header', appState, evaluateProp }).length).toBe(2)
94
140
  })
141
+
142
+ it('filters out panel-toggle button when panel is open and non-dismissible at current breakpoint', () => {
143
+ const state = { ...appState, panelConfig: { myPanel: { desktop: { open: true, dismissible: false } } } }
144
+ const config = { b1: { ...baseBtn, panelId: 'myPanel' } }
145
+ expect(getMatchingButtons({ buttonConfig: config, slot: 'header', appState: state, evaluateProp }).length).toBe(0)
146
+ })
147
+
148
+ it('includes panel-toggle button when panel is dismissible at current breakpoint', () => {
149
+ const state = { ...appState, panelConfig: { myPanel: { desktop: { open: true, dismissible: true } } } }
150
+ const config = { b1: { ...baseBtn, panelId: 'myPanel' } }
151
+ expect(getMatchingButtons({ buttonConfig: config, slot: 'header', appState: state, evaluateProp }).length).toBe(1)
152
+ })
95
153
  })
96
154
 
97
155
  // -------------------------
98
156
  // renderButton tests
99
157
  // -------------------------
100
158
  describe('renderButton', () => {
101
- const render = (config, state = appState, flags = {}) =>
102
- renderButton({ btn: ['id', config], appState: state, appConfig, evaluateProp, groupStart: false, groupMiddle: false, groupEnd: false, ...flags })
159
+ const render = (config, state = appState) =>
160
+ renderButton({ btn: ['id', config], appState: state, appConfig, evaluateProp })
103
161
 
104
162
  it('renders a MapButton with correct basic props', () => {
105
163
  const result = render(baseBtn)
106
164
  expect(result.props).toMatchObject({ buttonId: 'id', iconId: 'i1', label: 'Btn', showLabel: true })
107
165
  })
108
166
 
109
- it('applies group flags correctly', () => {
110
- const result = render(baseBtn, appState, { groupStart: true, groupEnd: true })
111
- expect(result.props).toMatchObject({ groupStart: true, groupEnd: true })
112
- })
113
-
114
167
  it('evaluates dynamic label, iconId, and href via evaluateProp', () => {
115
168
  const label = jest.fn(() => 'DynamicLabel')
116
169
  const iconId = jest.fn(() => 'DynamicIcon')
@@ -162,7 +215,7 @@ describe('mapButtons module', () => {
162
215
 
163
216
  it('uses empty object fallback for missing breakpoint config', () => {
164
217
  const result = render(baseBtn, { ...appState, breakpoint: 'mobile' })
165
- expect(result.props.showLabel).toBeUndefined()
218
+ expect(result.props.showLabel).toBe(true)
166
219
  })
167
220
 
168
221
  it('does nothing when clicked if button has no panelId and no onClick', () => {
@@ -192,21 +245,68 @@ describe('mapButtons module', () => {
192
245
  expect(result[0]).toMatchObject({ id: 'b1', type: 'button', order: 1 })
193
246
  })
194
247
 
195
- it('sets groupStart, groupMiddle, and groupEnd flags correctly for multiple buttons', () => {
248
+ it('renders grouped buttons as a single group item with role=group', () => {
249
+ appState.buttonConfig = ({
250
+ b1: { ...baseBtn, group: { name: 'g1', label: 'Group 1', order: 2 } },
251
+ b2: { ...baseBtn, desktop: { slot: 'header', order: 2 }, group: { name: 'g1', label: 'Group 1', order: 2 } }
252
+ })
253
+ const result = map()
254
+ expect(result).toHaveLength(1)
255
+ expect(result[0]).toMatchObject({ id: 'group-g1', type: 'group', order: 2 })
256
+ expect(result[0].element.props.role).toBe('group')
257
+ expect(result[0].element.props['aria-label']).toBe('Group 1')
258
+ })
259
+
260
+ it('uses group name as aria-label when no explicit label is provided', () => {
261
+ appState.buttonConfig = ({
262
+ b1: { ...baseBtn, group: { name: 'g1', order: 0 } },
263
+ b2: { ...baseBtn, group: { name: 'g1', order: 0 } }
264
+ })
265
+ const result = map()
266
+ expect(result[0].element.props['aria-label']).toBe('g1')
267
+ })
268
+
269
+ it('sorts group members by intra-group order', () => {
196
270
  appState.buttonConfig = ({
197
- b1: { ...baseBtn, group: 'g1' },
198
- b2: { ...baseBtn, desktop: { slot: 'header', order: 2 }, group: 'g1' },
199
- b3: { ...baseBtn, desktop: { slot: 'header', order: 3 }, group: 'g1' }
271
+ b1: { ...baseBtn, group: { name: 'g1', order: 0 }, desktop: { slot: 'header', order: 3 } },
272
+ b2: { ...baseBtn, group: { name: 'g1', order: 0 }, desktop: { slot: 'header', order: 1 } },
273
+ b3: { ...baseBtn, group: { name: 'g1', order: 0 }, desktop: { slot: 'header', order: 2 } }
200
274
  })
201
275
  const result = map()
202
- expect(result[0].element.props).toMatchObject({ groupStart: true })
203
- expect(result[1].element.props.groupMiddle).toBe(true)
204
- expect(result[2].element.props).toMatchObject({ groupEnd: true })
276
+ expect(result).toHaveLength(1)
277
+ const children = result[0].element.props.children
278
+ expect(children[0].props.buttonId).toBe('b2')
279
+ expect(children[1].props.buttonId).toBe('b3')
280
+ expect(children[2].props.buttonId).toBe('b1')
281
+ })
282
+
283
+ it('renders singleton groups as regular buttons using group slot order', () => {
284
+ appState.buttonConfig = ({ b1: { ...baseBtn, group: { name: 'g1', label: 'Group 1', order: 3 } } })
285
+ const result = map()
286
+ expect(result).toHaveLength(1)
287
+ expect(result[0]).toMatchObject({ id: 'b1', type: 'button', order: 3 })
288
+ })
289
+
290
+ it('falls back to breakpoint order for singleton group when group order is 0', () => {
291
+ appState.buttonConfig = ({ b1: { ...baseBtn, desktop: { slot: 'header', order: 4 }, group: { name: 'g1', order: 0 } } })
292
+ expect(map()[0].order).toBe(4)
293
+ })
294
+
295
+ it('falls back to 0 for singleton group when both group order and breakpoint order are absent', () => {
296
+ appState.buttonConfig = ({ b1: { ...baseBtn, desktop: { slot: 'header' }, group: { name: 'g1', order: 0 } } })
297
+ expect(map()[0].order).toBe(0)
205
298
  })
206
299
 
207
- it('ignores singleton groups when calculating group flags', () => {
208
- appState.buttonConfig = ({ b1: { ...baseBtn, group: 'g1' } })
209
- expect(map()[0].element.props).toMatchObject({ groupStart: false, groupEnd: false })
300
+ it('sorts group members treating missing breakpoint order as 0', () => {
301
+ appState.buttonConfig = ({
302
+ b1: { ...baseBtn, group: { name: 'g1', order: 0 }, desktop: { slot: 'header', order: 2 } },
303
+ b2: { ...baseBtn, group: { name: 'g1', order: 0 }, desktop: { slot: 'header' } },
304
+ b3: { ...baseBtn, group: { name: 'g1', order: 0 }, desktop: { slot: 'header', order: 1 } }
305
+ })
306
+ const children = map()[0].element.props.children
307
+ expect(children[0].props.buttonId).toBe('b2')
308
+ expect(children[1].props.buttonId).toBe('b3')
309
+ expect(children[2].props.buttonId).toBe('b1')
210
310
  })
211
311
 
212
312
  it('falls back to order 0 when order is not specified in breakpoint config', () => {
@@ -1,4 +1,5 @@
1
1
  // src/core/renderers/pluginWrapper.js
2
+ import { useMemo } from 'react'
2
3
  import { useConfig } from '../store/configContext.js'
3
4
  import { useApp } from '../store/appContext.js'
4
5
  import { useMap } from '../store/mapContext.js'
@@ -59,11 +60,11 @@ export function withPluginContexts (Component, { pluginId, pluginConfig }) {
59
60
  services={services}
60
61
  mapProvider={appConfig.mapProvider}
61
62
  iconRegistry={getIconRegistry()}
62
- buttonConfig={Object.fromEntries(
63
+ buttonConfig={useMemo(() => Object.fromEntries(
63
64
  Object.entries(appState.buttonConfig).filter(
64
65
  ([_, btn]) => btn.pluginId === pluginId
65
66
  )
66
- )}
67
+ ), [appState.buttonConfig])}
67
68
  />
68
69
  )
69
70
  })
@@ -30,10 +30,10 @@ export const allowedSlots = Object.freeze({
30
30
  layoutSlots.SIDE,
31
31
  layoutSlots.BANNER,
32
32
  layoutSlots.INSET,
33
+ layoutSlots.RIGHT_BOTTOM,
33
34
  layoutSlots.MIDDLE,
34
35
  layoutSlots.BOTTOM,
35
36
  layoutSlots.ACTIONS,
36
- layoutSlots.DRAWER,
37
37
  layoutSlots.MODAL
38
38
  ],
39
39
  button: [
@@ -21,6 +21,7 @@ export const AppProvider = ({ options, children }) => {
21
21
  topRightColRef: useRef(null),
22
22
  insetRef: useRef(null),
23
23
  rightRef: useRef(null),
24
+ rightBottomRef: useRef(null),
24
25
  middleRef: useRef(null),
25
26
  bottomRef: useRef(null),
26
27
  footerRef: useRef(null),
@@ -1,5 +1,5 @@
1
1
  // src/App/store/MapProvider.jsx
2
- import React, { createContext, useEffect, useReducer, useMemo } from 'react'
2
+ import React, { createContext, useEffect, useReducer, useMemo, useRef } from 'react'
3
3
  import { initialState, reducer } from './mapReducer.js'
4
4
  import { EVENTS as events } from '../../config/events.js'
5
5
 
@@ -8,8 +8,11 @@ export const MapContext = createContext(null)
8
8
  export const MapProvider = ({ options, children }) => {
9
9
  const [state, dispatch] = useReducer(reducer, initialState(options))
10
10
 
11
- const handleMapReady = (mapProvider) => {
12
- dispatch({ type: 'SET_MAP_READY', payload: mapProvider })
11
+ const { eventBus } = options
12
+ const isMapSizeInitialisedRef = useRef(false)
13
+
14
+ const handleMapReady = () => {
15
+ dispatch({ type: 'SET_MAP_READY' })
13
16
  }
14
17
 
15
18
  const handleInitMapStyles = (mapStyles) => {
@@ -30,8 +33,6 @@ export const MapProvider = ({ options, children }) => {
30
33
  }
31
34
 
32
35
  // Listen to eventBus and update state
33
- const { eventBus } = options
34
-
35
36
  useEffect(() => {
36
37
  eventBus.on(events.MAP_READY, handleMapReady)
37
38
  eventBus.on(events.MAP_INIT_MAP_STYLES, handleInitMapStyles)
@@ -46,6 +47,18 @@ export const MapProvider = ({ options, children }) => {
46
47
  }
47
48
  }, [])
48
49
 
50
+ // Emit map:sizechange when mapSize changes, skipping the initial value.
51
+ useEffect(() => {
52
+ if (!state.mapSize) {
53
+ return
54
+ }
55
+ if (!isMapSizeInitialisedRef.current) {
56
+ isMapSizeInitialisedRef.current = true
57
+ return
58
+ }
59
+ eventBus.emit(events.MAP_SIZE_CHANGE, { mapSize: state.mapSize })
60
+ }, [state.mapSize])
61
+
49
62
  // Persist mapStyle and mapSize in localStorage
50
63
  useEffect(() => {
51
64
  if (!state.mapStyle || !state.mapSize) {
@@ -71,6 +71,17 @@ describe('MapProvider', () => {
71
71
  expect(contextValue).toHaveProperty('isMapReady')
72
72
  })
73
73
 
74
+ test('subscribes to MAP_READY (not MAP_PROVIDER_READY)', () => {
75
+ render(
76
+ <MapProvider options={{ id: 'map1', mapSize: '100x100', eventBus: mockEventBus }}>
77
+ <div>Child</div>
78
+ </MapProvider>
79
+ )
80
+
81
+ expect(mockEventBus.on).toHaveBeenCalledWith('map:ready', expect.any(Function))
82
+ expect(mockEventBus.on).not.toHaveBeenCalledWith('map:providerready', expect.any(Function))
83
+ })
84
+
74
85
  test('subscribes and unsubscribes to eventBus', () => {
75
86
  render(
76
87
  <MapProvider options={{ id: 'map1', mapSize: '100x100', eventBus: mockEventBus }}>
@@ -78,7 +89,6 @@ describe('MapProvider', () => {
78
89
  </MapProvider>
79
90
  )
80
91
 
81
- // Ensure all events subscribed
82
92
  expect(mockEventBus.on).toHaveBeenCalledWith('map:ready', expect.any(Function))
83
93
  expect(mockEventBus.on).toHaveBeenCalledWith('map:initmapstyles', expect.any(Function))
84
94
  expect(mockEventBus.on).toHaveBeenCalledWith('map:setstyle', expect.any(Function))
@@ -93,6 +103,51 @@ describe('MapProvider', () => {
93
103
  })
94
104
  })
95
105
 
106
+ test('dispatches SET_MAP_READY when MAP_READY fires', () => {
107
+ let contextValue
108
+ const Child = () => {
109
+ contextValue = React.useContext(MapContext)
110
+ return null
111
+ }
112
+
113
+ render(
114
+ <MapProvider options={{ id: 'map1', mapSize: '100x100', eventBus: mockEventBus }}>
115
+ <Child />
116
+ </MapProvider>
117
+ )
118
+
119
+ expect(contextValue.isMapReady).toBe(false)
120
+
121
+ act(() => {
122
+ capturedHandlers['map:ready']()
123
+ })
124
+
125
+ expect(contextValue.isMapReady).toBe(true)
126
+ })
127
+
128
+ test('emits map:sizechange when mapSize changes after initial value', () => {
129
+ render(
130
+ <MapProvider options={{ id: 'map1', mapSize: '100x100', eventBus: mockEventBus }}>
131
+ <div>Child</div>
132
+ </MapProvider>
133
+ )
134
+
135
+ // Initial mapSize set via initmapstyles — should NOT emit sizechange
136
+ act(() => {
137
+ capturedHandlers['map:initmapstyles']([{ id: 'style1' }])
138
+ })
139
+
140
+ const afterInitCalls = mockEventBus.emit.mock.calls.filter(([event]) => event === 'map:sizechange')
141
+ expect(afterInitCalls).toHaveLength(0)
142
+
143
+ // Subsequent size change — SHOULD emit sizechange
144
+ act(() => {
145
+ capturedHandlers['map:setsize']('large')
146
+ })
147
+
148
+ expect(mockEventBus.emit).toHaveBeenCalledWith('map:sizechange', { mapSize: 'large' })
149
+ })
150
+
96
151
  test('initMapStyles uses options.mapSize if localStorage has no saved mapSize', () => {
97
152
  const options = { id: 'map1', mapSize: '200x200', eventBus: mockEventBus }
98
153
  const mapStyles = [{ id: 'style1' }]
@@ -74,19 +74,27 @@ const setBreakpoint = (state, payload) => {
74
74
  ? state.isFullscreen
75
75
  : getIsFullscreen({ behaviour, hybridWidth, maxMobileWidth })
76
76
 
77
+ const transitionedOpenPanels = lastPanelId
78
+ ? buildOpenPanels(state, lastPanelId, payload.breakpoint, state.openPanels[lastPanelId]?.props || {})
79
+ : {}
80
+
81
+ // Restore panels that are non-dismissible and always open at the new breakpoint
82
+ const panelConfig = state.panelConfig || state.panelRegistry.getPanelConfig()
83
+ const persistentPanels = Object.fromEntries(
84
+ Object.entries(panelConfig)
85
+ .filter(([panelId, config]) => {
86
+ const bpConfig = config[payload.breakpoint]
87
+ return bpConfig?.open === true && bpConfig?.dismissible === false && !transitionedOpenPanels[panelId]
88
+ })
89
+ .map(([panelId]) => [panelId, state.openPanels[panelId] || { props: {} }])
90
+ )
91
+
77
92
  return {
78
93
  ...state,
79
94
  breakpoint: payload.breakpoint,
80
95
  isFullscreen,
81
96
  previousOpenPanels: state.openPanels,
82
- openPanels: lastPanelId
83
- ? buildOpenPanels(
84
- state,
85
- lastPanelId,
86
- payload.breakpoint,
87
- state.openPanels[lastPanelId]?.props || {}
88
- )
89
- : {}
97
+ openPanels: { ...transitionedOpenPanels, ...persistentPanels }
90
98
  }
91
99
  }
92
100
 
@@ -289,7 +297,7 @@ const addPanel = (state, payload) => {
289
297
 
290
298
  // Check if panel should be initially open
291
299
  const bpConfig = panel?.[state.breakpoint]
292
- const shouldOpen = bpConfig?.initiallyOpen
300
+ const shouldOpen = bpConfig?.open
293
301
 
294
302
  return {
295
303
  ...state,