@defra/interactive-map 0.0.9-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.
- package/DOCS_README.md +39 -0
- package/README.md +1 -1
- 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/button-definition.md +21 -3
- package/docs/api/panel-definition.md +10 -12
- package/docs/api.md +81 -8
- package/docs/architecture/architecture-diagrams.md +1 -3
- package/docs/architecture/diagrams-viewer.mdx +12 -0
- package/docs/demo.mdx +70 -0
- package/docs/govuk-prototype.md +23 -0
- package/docs/index.md +19 -0
- package/docs/plugins/plugin-context.md +3 -3
- package/docs/plugins/plugin-manifest.md +1 -1
- package/docusaurus.config.cjs +136 -0
- package/mise.toml +2 -0
- package/package.json +27 -5
- 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/datasets/src/manifest.js +3 -3
- package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
- package/plugins/beta/draw-es/src/api/newPolygon.js +1 -3
- package/plugins/beta/draw-es/src/events.js +2 -2
- 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/draw-ml/src/api/newLine.js +2 -2
- package/plugins/beta/draw-ml/src/api/newPolygon.js +2 -2
- package/plugins/beta/draw-ml/src/events.js +18 -10
- package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/src/manifest.js +3 -3
- package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
- package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
- package/plugins/beta/use-location/src/manifest.js +7 -7
- package/plugins/search/dist/css/index.css +1 -1
- package/plugins/search/dist/esm/im-search-plugin.js +1 -1
- package/plugins/search/dist/esm/index.js +1 -1
- package/plugins/search/dist/umd/im-search-plugin.js +1 -1
- package/plugins/search/dist/umd/index.js +1 -1
- package/plugins/search/src/Search.jsx +9 -3
- package/plugins/search/src/Search.test.jsx +190 -0
- package/plugins/search/src/components/CloseButton/CloseButton.test.jsx +67 -0
- package/plugins/search/src/components/Form/Form.jsx +35 -7
- package/plugins/search/src/components/Form/Form.module.scss +27 -0
- package/plugins/search/src/components/Form/Form.test.jsx +255 -0
- package/plugins/search/src/components/OpenButton/OpenButton.test.jsx +47 -0
- package/plugins/search/src/components/SubmitButton/SubmitButton.jsx +28 -0
- package/plugins/search/src/components/SubmitButton/SubmitButton.module.scss +8 -0
- package/plugins/search/src/components/SubmitButton/SubmitButton.test.jsx +33 -0
- package/plugins/search/src/components/Suggestions/Suggestions.test.jsx +79 -0
- package/plugins/search/src/datasets.js +15 -11
- package/plugins/search/src/datasets.test.js +61 -0
- package/plugins/search/src/events/fetchSuggestions.js +1 -1
- package/plugins/search/src/events/fetchSuggestions.test.js +212 -0
- package/plugins/search/src/events/formHandlers.test.js +232 -0
- package/plugins/search/src/events/index.test.js +118 -0
- package/plugins/search/src/events/inputHandlers.test.js +104 -0
- package/plugins/search/src/events/suggestionHandlers.test.js +166 -0
- package/plugins/search/src/index.js +1 -1
- package/plugins/search/src/index.test.js +47 -0
- package/plugins/search/src/reducer.js +9 -4
- package/plugins/search/src/reducer.test.js +85 -0
- package/plugins/search/src/search.scss +5 -1
- package/plugins/search/src/utils/parseOsNamesResults.js +20 -3
- package/plugins/search/src/utils/parseOsNamesResults.test.js +158 -0
- package/plugins/search/src/utils/updateMap.test.js +52 -0
- package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
- package/providers/beta/esri/src/appEvents.js +8 -2
- package/providers/beta/esri/src/esriProvider.js +6 -14
- package/providers/beta/esri/src/mapEvents.js +7 -1
- package/providers/beta/esri/src/utils/coords.js +33 -1
- package/providers/beta/esri/src/utils/coords.test.js +126 -0
- package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/esm/index.js +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/umd/index.js +1 -1
- package/providers/maplibre/src/appEvents.js +10 -1
- package/providers/maplibre/src/appEvents.test.js +13 -4
- package/providers/maplibre/src/index.js +5 -13
- package/providers/maplibre/src/index.test.js +34 -15
- package/providers/maplibre/src/mapEvents.js +9 -1
- package/providers/maplibre/src/maplibreProvider.js +14 -15
- package/providers/maplibre/src/maplibreProvider.test.js +14 -1
- package/providers/maplibre/src/utils/spatial.js +11 -0
- package/providers/maplibre/src/utils/spatial.test.js +12 -0
- package/src/App/components/Actions/Actions.module.scss +5 -4
- package/src/App/components/MapButton/MapButton.jsx +4 -16
- package/src/App/components/MapButton/MapButton.module.scss +12 -12
- package/src/App/components/MapButton/MapButton.test.jsx +0 -9
- package/src/App/components/Panel/Panel.jsx +6 -6
- package/src/App/components/Panel/Panel.test.jsx +14 -15
- package/src/App/components/Viewport/MapController.jsx +2 -1
- package/src/App/hooks/useLayoutMeasurements.js +1 -1
- package/src/App/hooks/useLayoutMeasurements.test.js +1 -1
- package/src/App/hooks/useMapProviderOverrides.js +21 -1
- package/src/App/hooks/useMapProviderOverrides.test.js +51 -2
- package/src/App/layout/Layout.jsx +4 -4
- package/src/App/layout/layout.module.scss +1 -0
- package/src/App/registry/panelRegistry.js +1 -10
- package/src/App/registry/panelRegistry.test.js +6 -11
- package/src/App/renderer/HtmlElementHost.jsx +11 -3
- package/src/App/renderer/HtmlElementHost.test.jsx +89 -0
- package/src/App/renderer/mapButtons.js +128 -28
- package/src/App/renderer/mapButtons.test.js +119 -19
- package/src/App/store/MapProvider.jsx +18 -5
- package/src/App/store/MapProvider.test.jsx +56 -1
- package/src/App/store/appActionsMap.js +17 -9
- package/src/App/store/appActionsMap.test.js +33 -7
- package/src/App/store/mapActionsMap.js +4 -7
- package/src/InteractiveMap/InteractiveMap.js +18 -0
- package/src/InteractiveMap/InteractiveMap.test.js +12 -0
- package/src/config/appConfig.js +17 -15
- package/src/config/events.js +41 -4
- package/src/config/getInitialOpenPanels.js +2 -2
- package/src/config/getInitialOpenPanels.test.js +7 -7
- package/src/types.js +13 -11
- 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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
102
|
-
renderButton({ btn: ['id', config], appState: state, appConfig, evaluateProp
|
|
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).
|
|
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('
|
|
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,
|
|
199
|
-
b3: { ...baseBtn,
|
|
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
|
|
203
|
-
|
|
204
|
-
expect(
|
|
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('
|
|
208
|
-
appState.buttonConfig = ({
|
|
209
|
-
|
|
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,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
|
|
12
|
-
|
|
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:
|
|
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?.
|
|
300
|
+
const shouldOpen = bpConfig?.open
|
|
293
301
|
|
|
294
302
|
return {
|
|
295
303
|
...state,
|
|
@@ -11,9 +11,10 @@ describe('actionsMap full coverage', () => {
|
|
|
11
11
|
|
|
12
12
|
beforeEach(() => {
|
|
13
13
|
const mockPanelConfig = {
|
|
14
|
-
panel1: { desktop: { exclusive: true, modal: false,
|
|
14
|
+
panel1: { desktop: { exclusive: true, modal: false, open: true }, mobile: { exclusive: true, modal: false } },
|
|
15
15
|
panel2: { desktop: { exclusive: false, modal: true }, mobile: { exclusive: false, modal: true } },
|
|
16
|
-
panel3: { desktop: { exclusive: false, modal: false }, mobile: { exclusive: false, modal: false } }
|
|
16
|
+
panel3: { desktop: { exclusive: false, modal: false }, mobile: { exclusive: false, modal: false } },
|
|
17
|
+
panel4: { desktop: { open: true, dismissible: false }, mobile: { open: true, dismissible: true } }
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
state = {
|
|
@@ -171,15 +172,15 @@ describe('actionsMap full coverage', () => {
|
|
|
171
172
|
expect(result.panelConfig.panelX).toBeDefined()
|
|
172
173
|
})
|
|
173
174
|
|
|
174
|
-
test('ADD_PANEL adds panelConfig and opens
|
|
175
|
-
const payload = { id: 'panelY', config: { desktop: {
|
|
175
|
+
test('ADD_PANEL adds panelConfig and opens panel when open=true', () => {
|
|
176
|
+
const payload = { id: 'panelY', config: { desktop: { open: true } } }
|
|
176
177
|
const result = actionsMap.ADD_PANEL(state, payload)
|
|
177
178
|
expect(result.panelConfig.panelY).toBeDefined()
|
|
178
179
|
expect(result.openPanels.panelY).toBeDefined()
|
|
179
180
|
})
|
|
180
181
|
|
|
181
|
-
test('ADD_PANEL does not open if
|
|
182
|
-
const payload = { id: 'panelZ', config: { desktop: {
|
|
182
|
+
test('ADD_PANEL does not open if open=false', () => {
|
|
183
|
+
const payload = { id: 'panelZ', config: { desktop: { open: false } } }
|
|
183
184
|
const result = actionsMap.ADD_PANEL(state, payload)
|
|
184
185
|
expect(result.panelConfig.panelZ).toBeDefined()
|
|
185
186
|
expect(result.openPanels.panelZ).toBeUndefined()
|
|
@@ -274,6 +275,31 @@ describe('actionsMap full coverage', () => {
|
|
|
274
275
|
expect(result.isFullscreen).toBe(true)
|
|
275
276
|
})
|
|
276
277
|
|
|
278
|
+
test('SET_BREAKPOINT restores non-dismissible open panel at new breakpoint', () => {
|
|
279
|
+
const tmp = { ...state, openPanels: {} }
|
|
280
|
+
const result = actionsMap.SET_BREAKPOINT(tmp, { breakpoint: 'desktop', behaviour: 'responsive', hybridWidth: null, maxMobileWidth: 640 })
|
|
281
|
+
expect(result.openPanels.panel4).toBeDefined()
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('SET_BREAKPOINT does not force-open a non-dismissible panel where it is dismissible', () => {
|
|
285
|
+
const tmp = { ...state, openPanels: {} }
|
|
286
|
+
const result = actionsMap.SET_BREAKPOINT(tmp, { breakpoint: 'mobile', behaviour: 'responsive', hybridWidth: null, maxMobileWidth: 640 })
|
|
287
|
+
expect(result.openPanels.panel4).toBeUndefined()
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test('SET_BREAKPOINT preserves existing props when restoring a non-dismissible panel', () => {
|
|
291
|
+
const props = { myProp: 'value' }
|
|
292
|
+
const tmp = { ...state, openPanels: { panel4: { props } } }
|
|
293
|
+
const result = actionsMap.SET_BREAKPOINT(tmp, { breakpoint: 'desktop', behaviour: 'responsive', hybridWidth: null, maxMobileWidth: 640 })
|
|
294
|
+
expect(result.openPanels.panel4.props).toEqual(props)
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
test('SET_BREAKPOINT uses panelRegistry.getPanelConfig() when panelConfig missing', () => {
|
|
298
|
+
const tmp = { ...state, panelConfig: undefined, openPanels: {} }
|
|
299
|
+
const result = actionsMap.SET_BREAKPOINT(tmp, { breakpoint: 'desktop', behaviour: 'responsive', hybridWidth: null, maxMobileWidth: 640 })
|
|
300
|
+
expect(result.openPanels.panel4).toBeDefined()
|
|
301
|
+
})
|
|
302
|
+
|
|
277
303
|
test('SET_HYBRID_FULLSCREEN updates isFullscreen', () => {
|
|
278
304
|
const tmp = { ...state, isFullscreen: false }
|
|
279
305
|
const result = actionsMap.SET_HYBRID_FULLSCREEN(tmp, true)
|
|
@@ -294,7 +320,7 @@ describe('actionsMap full coverage', () => {
|
|
|
294
320
|
|
|
295
321
|
test('ADD_PANEL skips registry if panelRegistry missing', () => {
|
|
296
322
|
const tmp = { ...state, panelRegistry: undefined }
|
|
297
|
-
const payload = { id: 'panelY', config: { desktop: {
|
|
323
|
+
const payload = { id: 'panelY', config: { desktop: { open: true } } }
|
|
298
324
|
const result = actionsMap.ADD_PANEL(tmp, payload)
|
|
299
325
|
expect(result.panelConfig.panelY).toBeDefined()
|
|
300
326
|
expect(result.openPanels.panelY).toBeDefined()
|
|
@@ -7,13 +7,10 @@ const mergePayload = (state, payload) => ({
|
|
|
7
7
|
...payload
|
|
8
8
|
})
|
|
9
9
|
|
|
10
|
-
const setMapReady = (state
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
mapProvider
|
|
15
|
-
}
|
|
16
|
-
}
|
|
10
|
+
const setMapReady = (state) => ({
|
|
11
|
+
...state,
|
|
12
|
+
isMapReady: true
|
|
13
|
+
})
|
|
17
14
|
|
|
18
15
|
const setMapStyle = (state, payload) => {
|
|
19
16
|
return {
|