@defra/interactive-map 0.0.41-alpha → 0.0.43-fmp-experimental

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 (79) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/umd/im-core.js +1 -1
  4. package/dist/umd/index.js +1 -1
  5. package/docs/api/button-definition.md +7 -8
  6. package/docs/api/control-definition.md +8 -0
  7. package/docs/api/panel-definition.md +5 -0
  8. package/docs/api/slots.md +24 -1
  9. package/docs/examples/add-polygons.mdx +8 -2
  10. package/docs/examples/add-symbols.mdx +8 -2
  11. package/docs/plugins/datasets.md +23 -15
  12. package/docs/plugins/map-key.md +147 -0
  13. package/docs/plugins.md +5 -1
  14. package/package.json +1 -1
  15. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  16. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  17. package/plugins/beta/scale-bar/dist/css/index.css +2 -25
  18. package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
  19. package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
  20. package/plugins/beta/use-location/src/manifest.js +1 -1
  21. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  22. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  23. package/plugins/datasets/src/registry/dataset.js +12 -0
  24. package/plugins/datasets/src/registry/datasetRegistry.js +4 -2
  25. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
  26. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  27. package/plugins/draw/dist/umd/index.js +1 -1
  28. package/plugins/draw/src/api/createNewShape.js +41 -0
  29. package/plugins/draw/src/api/createNewShape.test.js +63 -0
  30. package/plugins/draw/src/api/newLine.js +2 -40
  31. package/plugins/draw/src/api/newPolygon.js +2 -40
  32. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  33. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  34. package/plugins/interact/dist/umd/index.js +1 -1
  35. package/plugins/interact/src/manifest.js +1 -1
  36. package/plugins/map-key/dist/css/index.css +55 -11
  37. package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -1
  38. package/plugins/map-key/dist/umd/im-map-key-plugin.js +1 -1
  39. package/plugins/map-key/dist/umd/index.js +1 -1
  40. package/plugins/map-key/src/components/Key/Key.jsx +6 -3
  41. package/plugins/map-key/src/components/Key/Key.module.scss +66 -13
  42. package/plugins/map-key/src/components/Key/KeyGroupItem.jsx +12 -8
  43. package/plugins/map-key/src/components/Key/KeyGroupItem.test.jsx +10 -0
  44. package/plugins/map-key/src/components/Key/KeyItem.jsx +9 -12
  45. package/plugins/map-key/src/components/Key/KeyItem.test.jsx +3 -3
  46. package/plugins/map-key/src/components/Key/KeySvg.jsx +27 -22
  47. package/plugins/map-key/src/components/Key/KeySvg.test.jsx +9 -0
  48. package/plugins/map-key/src/components/Key/KeySvgLine.jsx +3 -9
  49. package/plugins/map-key/src/components/Key/KeySvgPattern.jsx +2 -7
  50. package/plugins/map-key/src/components/Key/KeySvgPattern.test.jsx +6 -0
  51. package/plugins/map-key/src/components/Key/KeySvgRamp.jsx +13 -0
  52. package/plugins/map-key/src/components/Key/KeySvgRamp.test.jsx +43 -0
  53. package/plugins/map-key/src/components/Key/KeySvgRect.jsx +4 -11
  54. package/plugins/map-key/src/components/Key/KeySvgSymbol.jsx +5 -11
  55. package/plugins/map-key/src/components/Key/KeySvgSymbol.test.jsx +12 -0
  56. package/plugins/map-key/src/components/Key/MapKey.jsx +5 -2
  57. package/plugins/map-key/src/components/Key/MapKey.test.jsx +28 -11
  58. package/plugins/map-key/src/components/Key/svgProperties.js +2 -2
  59. package/plugins/map-key/src/components/Key/svgProperties.test.js +2 -2
  60. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +25 -23
  61. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +22 -5
  62. package/src/App/components/Panel/Panel.jsx +50 -8
  63. package/src/App/components/Panel/Panel.module.scss +31 -7
  64. package/src/App/components/Panel/Panel.test.jsx +81 -2
  65. package/src/App/components/Tabs/Tabs.jsx +43 -17
  66. package/src/App/components/Tabs/Tabs.module.scss +32 -5
  67. package/src/App/components/Tabs/Tabs.test.jsx +40 -10
  68. package/src/App/renderer/groupByKey.js +28 -0
  69. package/src/App/renderer/groupByKey.test.js +39 -0
  70. package/src/App/renderer/groupIntoTabs.js +42 -0
  71. package/src/App/renderer/groupIntoTabs.test.js +95 -0
  72. package/src/App/renderer/mapButtons.js +74 -122
  73. package/src/App/renderer/mapButtons.test.js +51 -80
  74. package/src/App/renderer/mapControls.js +1 -0
  75. package/src/App/renderer/mapControls.test.js +16 -0
  76. package/src/App/renderer/mapPanels.js +26 -19
  77. package/src/App/renderer/mapPanels.test.js +44 -0
  78. package/src/config/appConfig.js +2 -2
  79. package/src/types.js +20 -2
@@ -1,6 +1,8 @@
1
1
  // src/core/renderers/mapButtons.js
2
2
  import { MapButton } from '../components/MapButton/MapButton.jsx'
3
3
  import { allowedSlots } from './slots.js'
4
+ import { groupByKey } from './groupByKey.js'
5
+ import { orderItems } from './orderItems.js'
4
6
  import { logger } from '../../services/logger.js'
5
7
 
6
8
  function getMatchingButtons ({ appState, buttonConfig, slot, evaluateProp }) {
@@ -74,47 +76,6 @@ function createButtonClickHandler (config, appState, evaluateProp) {
74
76
  }
75
77
  }
76
78
 
77
- /**
78
- * Resolves the group name from a button config's group property.
79
- * Accepts either the new object form `{ name, label?, order? }` or a deprecated plain string.
80
- * @param {string|{name: string, label?: string, order?: number}|null|undefined} group
81
- * @returns {string|null}
82
- */
83
- function resolveGroupName (group) {
84
- if (group == null) {
85
- return null
86
- }
87
- return typeof group === 'string' ? group : (group.name ?? null)
88
- }
89
-
90
- /**
91
- * Resolves the accessible label for a group.
92
- * Uses `label` if provided, otherwise falls back to `name`.
93
- * @param {string|{name: string, label?: string, order?: number}|null|undefined} group
94
- * @returns {string}
95
- */
96
- function resolveGroupLabel (group) {
97
- if (!group) {
98
- return ''
99
- }
100
- if (typeof group === 'string') {
101
- return group
102
- }
103
- return group.label ?? group.name ?? ''
104
- }
105
-
106
- /**
107
- * Resolves the slot-level order for a group.
108
- * @param {string|{name: string, label?: string, order?: number}|null|undefined} group
109
- * @returns {number}
110
- */
111
- function resolveGroupOrder (group) {
112
- if (!group || typeof group === 'string') {
113
- return 0
114
- }
115
- return group.order ?? 0
116
- }
117
-
118
79
  function applySlotExclusivity (matching, appState) {
119
80
  let exclusivePluginId = null
120
81
 
@@ -181,6 +142,69 @@ function SlotButton ({ buttonId, config, appState, appConfig, evaluateProp }) {
181
142
  )
182
143
  }
183
144
 
145
+ /**
146
+ * Ungrouped buttons — one result item per button, ordered by its own breakpoint-level
147
+ * slot position.
148
+ */
149
+ function buildUngroupedItems (members, ctx) {
150
+ return members.map(([buttonId, config]) => ({
151
+ id: buttonId,
152
+ type: 'button',
153
+ order: config[ctx.breakpoint]?.order ?? 0,
154
+ element: <SlotButton key={buttonId} {...slotButtonProps({ buttonId, config, ...ctx })} />
155
+ }))
156
+ }
157
+
158
+ /**
159
+ * A named group's own slot order/label come from whichever member is encountered first —
160
+ * unlike panel tabs (see groupIntoTabs.js), this is never derived from members' own order:
161
+ * groups render simultaneously (not one-at-a-time like tabs), so an unspecified order
162
+ * defaults to 0, same as every other slot item, rather than borrowing a member's position.
163
+ * A single-member group still degrades to a plain button (no wrapping container), but keeps
164
+ * the group's own slot order rather than falling back to its own breakpoint-level order.
165
+ */
166
+ function buildGroupItem (key, members, ctx) {
167
+ const [, firstConfig] = members[0]
168
+ const order = firstConfig.group.slotOrder ?? 0
169
+
170
+ /* istanbul ignore next */
171
+ if (process.env.NODE_ENV !== 'production') {
172
+ const distinctOrders = new Set(members.map(([, config]) => config.group?.slotOrder ?? 0))
173
+ if (distinctOrders.size > 1) {
174
+ logger.warn(`Button group "${firstConfig.group.label}" has inconsistent slotOrder values (${[...distinctOrders].join(', ')}) across its members — using ${order} (the first member's).`)
175
+ }
176
+ }
177
+
178
+ if (members.length < 2) {
179
+ const [buttonId, config] = members[0]
180
+ return {
181
+ id: buttonId,
182
+ type: 'button',
183
+ order,
184
+ element: <SlotButton key={buttonId} {...slotButtonProps({ buttonId, config, ...ctx })} />
185
+ }
186
+ }
187
+
188
+ // Order members within the group the same way panel/control content orders within a tab
189
+ const sorted = orderItems(members.map(([buttonId, config]) => ({
190
+ id: buttonId,
191
+ order: config[ctx.breakpoint]?.order ?? 0,
192
+ buttonId,
193
+ config
194
+ })))
195
+
196
+ return {
197
+ id: `group-${key}`,
198
+ type: 'group',
199
+ order,
200
+ element: (
201
+ <div key={`group-${key}`} role='group' aria-label={firstConfig.group.label} className='im-c-button-group'>{/* NOSONAR - div with role="group" is correct for a button group */}
202
+ {sorted.map(({ buttonId, config }) => <SlotButton key={buttonId} {...slotButtonProps({ buttonId, config, ...ctx })} />)}
203
+ </div>
204
+ )
205
+ }
206
+ }
207
+
184
208
  function mapButtons ({ slot, appState, appConfig, evaluateProp }) {
185
209
  const { buttonConfig, breakpoint } = appState
186
210
 
@@ -191,86 +215,17 @@ function mapButtons ({ slot, appState, appConfig, evaluateProp }) {
191
215
  return []
192
216
  }
193
217
 
194
- // Partition matching buttons into named groups and ungrouped singletons
195
- const groupMap = new Map() // name -> { label, order, members: [[buttonId, config]] }
196
- const singletons = []
197
-
198
- matching.forEach(([buttonId, config]) => {
199
- const { group } = config
200
-
201
- if (group == null) {
202
- singletons.push([buttonId, config])
203
- return
204
- }
205
-
206
- /* istanbul ignore next */
207
- if (process.env.NODE_ENV !== 'production' && typeof group === 'string') {
208
- logger.warn(`Button "${buttonId}": group should be an object { name, label?, order? } — string groups are deprecated.`)
209
- }
210
-
211
- const name = resolveGroupName(group)
212
- const label = resolveGroupLabel(group)
213
- const order = resolveGroupOrder(group)
214
-
215
- if (groupMap.has(name)) {
216
- const existing = groupMap.get(name)
217
- /* istanbul ignore next */
218
- if (process.env.NODE_ENV !== 'production' && existing.order !== order) {
219
- logger.warn(`Group "${name}" has inconsistent order values (${existing.order} vs ${order}). Using the lower value.`)
220
- existing.order = Math.min(existing.order, order)
221
- }
222
- } else {
223
- groupMap.set(name, { label, order, members: [] })
224
- }
225
-
226
- groupMap.get(name).members.push([buttonId, config])
227
- })
218
+ // Partition into named groups (keyed by kebab-cased group.label) plus one ungrouped bucket
219
+ const buckets = groupByKey({ items: matching, keyFn: ([, config]) => config.group?.label })
220
+ const ctx = { breakpoint, appState, appConfig, evaluateProp }
228
221
 
229
222
  const result = []
230
-
231
- // Ungrouped buttons order is the breakpoint-level slot position
232
- for (const btn of singletons) {
233
- const [buttonId, config] = btn
234
- const order = config[breakpoint]?.order ?? 0
235
- result.push({
236
- id: buttonId,
237
- type: 'button',
238
- order,
239
- element: <SlotButton key={buttonId} {...slotButtonProps({ buttonId, config, appState, appConfig, evaluateProp })} />
240
- })
241
- }
242
-
243
- for (const [groupName, { label, order: groupOrder, members }] of groupMap) {
244
- if (members.length < 2) {
245
- // Singleton group: degrade to a regular button using the group's slot order
246
- const [buttonId, config] = members[0]
247
- const order = groupOrder || config[breakpoint]?.order || 0
248
- result.push({
249
- id: buttonId,
250
- type: 'button',
251
- order,
252
- element: <SlotButton key={buttonId} {...slotButtonProps({ buttonId, config, appState, appConfig, evaluateProp })} />
253
- })
254
- continue
223
+ for (const [key, members] of buckets) {
224
+ if (key === null) {
225
+ result.push(...buildUngroupedItems(members, ctx))
226
+ } else {
227
+ result.push(buildGroupItem(key, members, ctx))
255
228
  }
256
-
257
- // Sort group members by their intra-group order (breakpoint-level order prop)
258
- const sorted = [...members].sort((a, b) => {
259
- const orderA = a[1][breakpoint]?.order ?? 0
260
- const orderB = b[1][breakpoint]?.order ?? 0
261
- return orderA - orderB
262
- })
263
-
264
- result.push({
265
- id: `group-${groupName}`,
266
- type: 'group',
267
- order: groupOrder,
268
- element: (
269
- <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 */}
270
- {sorted.map(([buttonId, config]) => <SlotButton key={buttonId} {...slotButtonProps({ buttonId, config, appState, appConfig, evaluateProp })} />)}
271
- </div>
272
- )
273
- })
274
229
  }
275
230
 
276
231
  return result
@@ -280,8 +235,5 @@ export {
280
235
  mapButtons,
281
236
  getMatchingButtons,
282
237
  applySlotExclusivity,
283
- SlotButton,
284
- resolveGroupName,
285
- resolveGroupLabel,
286
- resolveGroupOrder
238
+ SlotButton
287
239
  }
@@ -1,5 +1,5 @@
1
1
  import React from 'react'
2
- import { mapButtons, getMatchingButtons, applySlotExclusivity, SlotButton, resolveGroupName, resolveGroupLabel, resolveGroupOrder } from './mapButtons.js'
2
+ import { mapButtons, getMatchingButtons, applySlotExclusivity, SlotButton } from './mapButtons.js'
3
3
  import { logger } from '../../services/logger.js'
4
4
  import { getPanelConfig } from '../registry/panelRegistry.js'
5
5
 
@@ -47,52 +47,6 @@ describe('mapButtons module', () => {
47
47
  getPanelConfig.mockReturnValue({})
48
48
  })
49
49
 
50
- // -------------------------
51
- // resolveGroup* helper tests
52
- // -------------------------
53
- describe('resolveGroupName', () => {
54
- it('returns null when group is null or undefined', () => {
55
- expect(resolveGroupName(null)).toBeNull()
56
- expect(resolveGroupName(undefined)).toBeNull()
57
- })
58
- it('returns the string when group is a string', () => {
59
- expect(resolveGroupName('g1')).toBe('g1')
60
- })
61
- it('returns group.name when group is an object', () => {
62
- expect(resolveGroupName({ name: 'g1' })).toBe('g1')
63
- expect(resolveGroupName({ name: undefined })).toBeNull()
64
- })
65
- })
66
-
67
- describe('resolveGroupLabel', () => {
68
- it('returns empty string when group is falsy', () => {
69
- expect(resolveGroupLabel(null)).toBe('')
70
- expect(resolveGroupLabel(undefined)).toBe('')
71
- })
72
- it('returns the string itself when group is a string', () => {
73
- expect(resolveGroupLabel('My Group')).toBe('My Group')
74
- })
75
- it('returns group.label when provided, else group.name, else empty string', () => {
76
- expect(resolveGroupLabel({ name: 'g1', label: 'Group One' })).toBe('Group One')
77
- expect(resolveGroupLabel({ name: 'g1' })).toBe('g1')
78
- expect(resolveGroupLabel({ order: 5 })).toBe('')
79
- })
80
- })
81
-
82
- describe('resolveGroupOrder', () => {
83
- it('returns 0 when group is falsy', () => {
84
- expect(resolveGroupOrder(null)).toBe(0)
85
- expect(resolveGroupOrder(undefined)).toBe(0)
86
- })
87
- it('returns 0 when group is a string', () => {
88
- expect(resolveGroupOrder('g1')).toBe(0)
89
- })
90
- it('returns group.order when provided, else 0', () => {
91
- expect(resolveGroupOrder({ name: 'g1', order: 5 })).toBe(5)
92
- expect(resolveGroupOrder({ name: 'g1' })).toBe(0)
93
- })
94
- })
95
-
96
50
  // -------------------------
97
51
  // getMatchingButtons tests
98
52
  // -------------------------
@@ -283,68 +237,85 @@ describe('mapButtons module', () => {
283
237
  expect(result[0]).toMatchObject({ id: 'b1', type: 'button', order: 1 })
284
238
  })
285
239
 
286
- it('renders grouped buttons as a single group item with role=group', () => {
240
+ it('renders grouped buttons as a single group item with role=group, keyed by kebab-cased label', () => {
287
241
  appState.buttonConfig = ({
288
- b1: { ...baseBtn, group: { name: 'g1', label: 'Group 1', order: 2 } },
289
- b2: { ...baseBtn, desktop: { slot: 'header', order: 2 }, group: { name: 'g1', label: 'Group 1', order: 2 } }
242
+ b1: { ...baseBtn, group: { label: 'Group 1', slotOrder: 2 } },
243
+ b2: { ...baseBtn, desktop: { slot: 'header', order: 2 }, group: { label: 'Group 1', slotOrder: 2 } }
290
244
  })
291
245
  const result = map()
292
246
  expect(result).toHaveLength(1)
293
- expect(result[0]).toMatchObject({ id: 'group-g1', type: 'group', order: 2 })
247
+ // stringToKebab only hyphenates camelCase boundaries, not spaces — this id is an internal
248
+ // React key/identifier only, never rendered as a literal DOM id, so that's harmless here.
249
+ expect(result[0]).toMatchObject({ id: 'group-group 1', type: 'group', order: 2 })
294
250
  expect(result[0].element.props.role).toBe('group')
295
251
  expect(result[0].element.props['aria-label']).toBe('Group 1')
296
252
  })
297
253
 
298
- it('uses group name as aria-label when no explicit label is provided', () => {
254
+ it('merges group labels that differ only by case/whitespace into one group', () => {
299
255
  appState.buttonConfig = ({
300
- b1: { ...baseBtn, group: { name: 'g1', order: 0 } },
301
- b2: { ...baseBtn, group: { name: 'g1', order: 0 } }
256
+ b1: { ...baseBtn, group: { label: 'Zoom Controls' } },
257
+ b2: { ...baseBtn, group: { label: 'zoom controls' } }
302
258
  })
303
259
  const result = map()
304
- expect(result[0].element.props['aria-label']).toBe('g1')
260
+ expect(result).toHaveLength(1)
261
+ // First-encountered member's raw label wins, same convention as panel tabs (groupIntoTabs.js)
262
+ expect(result[0].element.props['aria-label']).toBe('Zoom Controls')
305
263
  })
306
264
 
307
- it('sorts group members by intra-group order', () => {
265
+ it('orders group members via orderItems — unordered members keep natural sequence, an ordered one splices in by position', () => {
308
266
  appState.buttonConfig = ({
309
- b1: { ...baseBtn, group: { name: 'g1', order: 0 }, desktop: { slot: 'header', order: 3 } },
310
- b2: { ...baseBtn, group: { name: 'g1', order: 0 }, desktop: { slot: 'header', order: 1 } },
311
- b3: { ...baseBtn, group: { name: 'g1', order: 0 }, desktop: { slot: 'header', order: 2 } }
267
+ b1: { ...baseBtn, group: { label: 'g1' }, desktop: { slot: 'header' } },
268
+ b2: { ...baseBtn, group: { label: 'g1' }, desktop: { slot: 'header', order: 1 } },
269
+ b3: { ...baseBtn, group: { label: 'g1' }, desktop: { slot: 'header' } }
312
270
  })
313
- const result = map()
314
- expect(result).toHaveLength(1)
315
- const children = result[0].element.props.children
316
- expect(children[0].props.buttonId).toBe('b2')
317
- expect(children[1].props.buttonId).toBe('b3')
318
- expect(children[2].props.buttonId).toBe('b1')
271
+ const children = map()[0].element.props.children
272
+ expect(children.map(c => c.props.buttonId)).toEqual(['b2', 'b1', 'b3'])
319
273
  })
320
274
 
321
- it('renders singleton groups as regular buttons using group slot order', () => {
322
- appState.buttonConfig = ({ b1: { ...baseBtn, group: { name: 'g1', label: 'Group 1', order: 3 } } })
275
+ it('renders singleton groups as regular buttons using the group\'s slot order', () => {
276
+ appState.buttonConfig = ({ b1: { ...baseBtn, group: { label: 'Group 1', slotOrder: 3 } } })
323
277
  const result = map()
324
278
  expect(result).toHaveLength(1)
325
279
  expect(result[0]).toMatchObject({ id: 'b1', type: 'button', order: 3 })
326
280
  })
327
281
 
328
- it('falls back to breakpoint order for singleton group when group order is 0', () => {
329
- appState.buttonConfig = ({ b1: { ...baseBtn, desktop: { slot: 'header', order: 4 }, group: { name: 'g1', order: 0 } } })
330
- expect(map()[0].order).toBe(4)
282
+ it('does not fall back to the button\'s own breakpoint order when group slotOrder is explicitly 0', () => {
283
+ appState.buttonConfig = ({ b1: { ...baseBtn, desktop: { slot: 'header', order: 4 }, group: { label: 'g1', slotOrder: 0 } } })
284
+ expect(map()[0].order).toBe(0)
331
285
  })
332
286
 
333
- it('falls back to 0 for singleton group when both group order and breakpoint order are absent', () => {
334
- appState.buttonConfig = ({ b1: { ...baseBtn, desktop: { slot: 'header' }, group: { name: 'g1', order: 0 } } })
287
+ it('falls back to 0 for singleton group when group slotOrder is absent', () => {
288
+ appState.buttonConfig = ({ b1: { ...baseBtn, desktop: { slot: 'header' }, group: { label: 'g1' } } })
335
289
  expect(map()[0].order).toBe(0)
336
290
  })
337
291
 
338
- it('sorts group members treating missing breakpoint order as 0', () => {
292
+ it('warns in dev mode when group members declare inconsistent slotOrder values, using the first one encountered', () => {
339
293
  appState.buttonConfig = ({
340
- b1: { ...baseBtn, group: { name: 'g1', order: 0 }, desktop: { slot: 'header', order: 2 } },
341
- b2: { ...baseBtn, group: { name: 'g1', order: 0 }, desktop: { slot: 'header' } },
342
- b3: { ...baseBtn, group: { name: 'g1', order: 0 }, desktop: { slot: 'header', order: 1 } }
294
+ b1: { ...baseBtn, group: { label: 'g1', slotOrder: 2 } },
295
+ b2: { ...baseBtn, desktop: { slot: 'header', order: 1 }, group: { label: 'g1', slotOrder: 5 } }
343
296
  })
344
- const children = map()[0].element.props.children
345
- expect(children[0].props.buttonId).toBe('b2')
346
- expect(children[1].props.buttonId).toBe('b3')
347
- expect(children[2].props.buttonId).toBe('b1')
297
+ const result = map()
298
+ expect(result[0].order).toBe(2)
299
+ expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('inconsistent slotOrder values'))
300
+ })
301
+
302
+ it('does not warn when group members agree on slotOrder', () => {
303
+ appState.buttonConfig = ({
304
+ b1: { ...baseBtn, group: { label: 'g1', slotOrder: 2 } },
305
+ b2: { ...baseBtn, desktop: { slot: 'header', order: 1 }, group: { label: 'g1', slotOrder: 2 } }
306
+ })
307
+ map()
308
+ expect(logger.warn).not.toHaveBeenCalled()
309
+ })
310
+
311
+ it('mixes grouped and ungrouped buttons in the same slot', () => {
312
+ appState.buttonConfig = ({
313
+ solo: { ...baseBtn, desktop: { slot: 'header', order: 1 } },
314
+ b1: { ...baseBtn, group: { label: 'g1' }, desktop: { slot: 'header' } },
315
+ b2: { ...baseBtn, group: { label: 'g1' }, desktop: { slot: 'header' } }
316
+ })
317
+ const result = map()
318
+ expect(result.map(r => r.id).sort()).toEqual(['group-g1', 'solo'])
348
319
  })
349
320
 
350
321
  it('falls back to order 0 when order is not specified in breakpoint config', () => {
@@ -74,6 +74,7 @@ export function mapControls ({ slot, appState, evaluateProp }) {
74
74
  id: control.id,
75
75
  type: 'control',
76
76
  order: control[breakpoint]?.order ?? 0,
77
+ tab: control[breakpoint]?.tab,
77
78
  element
78
79
  }
79
80
  })
@@ -180,4 +180,20 @@ describe('mapControls', () => {
180
180
  const result = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p })
181
181
  expect(result).toEqual([])
182
182
  })
183
+
184
+ it('passes through the tab field from the breakpoint config', () => {
185
+ defaultAppState.controlConfig = ({
186
+ ctrl1: { id: 'ctrl1', desktop: { slot: 'map-styles-panel', tab: 'Styles' }, includeModes: ['view'] }
187
+ })
188
+ const result = mapControls({ slot: 'map-styles-panel', appState: defaultAppState, evaluateProp: (p) => p })
189
+ expect(result[0].tab).toBe('Styles')
190
+ })
191
+
192
+ it('leaves tab undefined when not set on the breakpoint config', () => {
193
+ defaultAppState.controlConfig = ({
194
+ ctrl1: { id: 'ctrl1', desktop: { slot: 'header', order: 1 }, includeModes: ['view'] }
195
+ })
196
+ const result = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p })
197
+ expect(result[0].tab).toBeUndefined()
198
+ })
183
199
  })
@@ -6,6 +6,7 @@ import { allowedSlots } from './slots.js'
6
6
  import { resolveTargetSlot, isModeAllowed, isConsumerHtml } from './slotHelpers.js'
7
7
  import { mapControls } from './mapControls.js'
8
8
  import { orderItems } from './orderItems.js'
9
+ import { groupIntoTabs } from './groupIntoTabs.js'
9
10
  import { stringToKebab } from '../../utils/stringToKebab.js'
10
11
  import { logger } from '../../services/logger.js'
11
12
 
@@ -48,7 +49,7 @@ export function mapPanels ({ slot, appState, evaluateProp }) {
48
49
  const cfg = panelConfig[panelId]?.[breakpoint]
49
50
  return cfg?.modal
50
51
  })
51
- const allowedModalPanelId = modalPanels.length > 0 ? modalPanels[modalPanels.length - 1][0] : null
52
+ const allowedModalPanelId = modalPanels.length > 0 ? modalPanels[modalPanels.length - 1][0] : null // NOSONAR, .at() is only Chrome 90+
52
53
 
53
54
  return openPanelEntries.map(([panelId, { props, focusOnOpen }]) => {
54
55
  const config = panelConfig[panelId]
@@ -78,6 +79,7 @@ export function mapPanels ({ slot, appState, evaluateProp }) {
78
79
  const pluginId = plugin?.id
79
80
 
80
81
  const html = pluginId ? evaluateProp(config.html, pluginId) : config.html
82
+ const label = evaluateProp(config.label, pluginId)
81
83
 
82
84
  return {
83
85
  id: panelId,
@@ -90,8 +92,8 @@ export function mapPanels ({ slot, appState, evaluateProp }) {
90
92
  panelConfig={config}
91
93
  props={props}
92
94
  focusOnOpen={focusOnOpen}
93
- items={buildPanelBodyItems({ panelId, config, props, plugin, pluginId, html, appState, evaluateProp })}
94
- label={evaluateProp(config.label, pluginId)}
95
+ {...buildPanelBody({ panelId, config, bpConfig, props, plugin, pluginId, html, label, appState, evaluateProp })}
96
+ label={label}
95
97
  html={html}
96
98
  />
97
99
  )
@@ -101,12 +103,16 @@ export function mapPanels ({ slot, appState, evaluateProp }) {
101
103
  }
102
104
 
103
105
  /**
104
- * Builds the ordered list of body items for a panel: its own render content (if any) plus
105
- * any controls registered against its `<panelId>-panel` slot by other plugins. Static-html
106
- * panels don't build an items list dangerouslySetInnerHTML can't host injected controls,
106
+ * Builds a panel's body: its own render content (if any) plus any controls registered against
107
+ * its `<panelId>-panel` slot by other plugins, merged with `orderItems` — or, when two or more
108
+ * distinct `tab`s are present among them, grouped into tabs instead (see `groupIntoTabs`).
109
+ * Static-html panels don't build a body — dangerouslySetInnerHTML can't host injected content,
107
110
  * so any controls targeting one are silently skipped (with a dev warning).
111
+ *
112
+ * @returns {{ items?: object[], tabs?: object[] }} spread directly onto `<Panel>` — exactly one
113
+ * of `items`/`tabs` is set (or neither, for a static-html panel).
108
114
  */
109
- function buildPanelBodyItems ({ panelId, config, props, plugin, pluginId, html, appState, evaluateProp }) {
115
+ function buildPanelBody ({ panelId, config, bpConfig, props, plugin, pluginId, html, label, appState, evaluateProp }) {
110
116
  const injectedItems = mapControls({
111
117
  slot: `${stringToKebab(panelId)}-panel`,
112
118
  appState,
@@ -118,21 +124,22 @@ function buildPanelBodyItems ({ panelId, config, props, plugin, pluginId, html,
118
124
  if (process.env.NODE_ENV !== 'production' && injectedItems.length > 0) {
119
125
  logger.warn(`Panel "${panelId}" uses static html — controls targeting its slot are not rendered.`)
120
126
  }
121
- return undefined
127
+ return {}
122
128
  }
123
129
 
124
- if (!config.render) {
125
- return injectedItems
130
+ let ownItem = null
131
+ if (config.render) {
132
+ const WrappedChild = withPluginContexts(config.render, {
133
+ ...props,
134
+ pluginId,
135
+ pluginConfig: plugin?.config
136
+ })
137
+ ownItem = { id: panelId, order: 0, tab: bpConfig.tab, element: <WrappedChild {...props} /> }
126
138
  }
127
139
 
128
- const WrappedChild = withPluginContexts(config.render, {
129
- ...props,
130
- pluginId,
131
- pluginConfig: plugin?.config
132
- })
140
+ const allItems = ownItem ? [ownItem, ...injectedItems] : injectedItems
141
+
142
+ const tabs = groupIntoTabs({ items: allItems, fallbackLabel: label })
133
143
 
134
- return orderItems([
135
- { id: panelId, order: 0, element: <WrappedChild {...props} /> },
136
- ...injectedItems
137
- ])
144
+ return tabs ? { tabs } : { items: orderItems(allItems) }
138
145
  }
@@ -170,6 +170,50 @@ describe('mapPanels', () => {
170
170
  expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('p1'))
171
171
  })
172
172
 
173
+ it('orders multiple injected controls even when the panel has no render (regression: previously returned mapControls output unordered)', () => {
174
+ mapControls.mockReturnValue([
175
+ { id: 'first', order: 2, element: <span>first</span> },
176
+ { id: 'second', order: 1, element: <span>second</span> }
177
+ ])
178
+ defaultAppState.panelConfig = ({ p1: { desktop: { slot: 'header' }, includeModes: ['view'] } })
179
+ const result = map()
180
+ expect(result[0].element.props.items.map(i => i.id)).toEqual(['second', 'first'])
181
+ })
182
+
183
+ describe('tabs', () => {
184
+ it('does not build a tabs list when everything shares one (or no) tab', () => {
185
+ const renderFn = () => <div>child</div>
186
+ mapControls.mockReturnValue([{ id: 'injected1', order: 0, element: <span>injected</span> }])
187
+ defaultAppState.panelConfig = ({ p1: { ...baseConfig, render: renderFn } })
188
+ const result = map()
189
+ expect(result[0].element.props.tabs).toBeUndefined()
190
+ expect(result[0].element.props.items).toBeDefined()
191
+ })
192
+
193
+ it('groups the panel\'s own content and an injected control into separate tabs', () => {
194
+ const renderFn = () => <div>child</div>
195
+ mapControls.mockReturnValue([{ id: 'injected1', order: 0, tab: 'Injected', element: <span>injected</span> }])
196
+ defaultAppState.panelConfig = ({
197
+ p1: { ...baseConfig, render: renderFn, desktop: { slot: 'header', order: 1, tab: 'Own' } }
198
+ })
199
+ const result = map()
200
+ expect(result[0].element.props.items).toBeUndefined()
201
+ const tabNames = result[0].element.props.tabs.map(t => t.name)
202
+ expect(tabNames.sort()).toEqual(['Injected', 'Own'])
203
+ })
204
+
205
+ it('falls back to the panel\'s own label for its content\'s implicit tab', () => {
206
+ const renderFn = () => <div>child</div>
207
+ mapControls.mockReturnValue([{ id: 'injected1', order: 0, tab: 'Injected', element: <span>injected</span> }])
208
+ defaultAppState.panelConfig = ({
209
+ p1: { ...baseConfig, render: renderFn, label: 'Map styles' }
210
+ })
211
+ const result = map()
212
+ const tabNames = result[0].element.props.tabs.map(t => t.name)
213
+ expect(tabNames).toContain('Map styles')
214
+ })
215
+ })
216
+
173
217
  it('returns correct structure and defaults', () => {
174
218
  defaultAppState.panelConfig = ({ p1: { desktop: { slot: 'header' }, includeModes: ['view'] } })
175
219
  const result = map()
@@ -74,7 +74,7 @@ export const defaultAppConfig = {
74
74
  desktop: buttonSlots
75
75
  }, {
76
76
  id: 'zoomIn',
77
- group: { name: 'zoom', label: 'Zoom controls', order: 0 },
77
+ group: { label: 'Zoom controls', slotOrder: 0 },
78
78
  label: 'Zoom in',
79
79
  iconId: 'plus',
80
80
  keepFocus: true,
@@ -86,7 +86,7 @@ export const defaultAppConfig = {
86
86
  desktop: buttonSlots
87
87
  }, {
88
88
  id: 'zoomOut',
89
- group: { name: 'zoom', label: 'Zoom controls', order: 0 },
89
+ group: { label: 'Zoom controls', slotOrder: 0 },
90
90
  label: 'Zoom out',
91
91
  iconId: 'minus',
92
92
  keepFocus: true,
package/src/types.js CHANGED
@@ -40,6 +40,15 @@
40
40
  * different plugin. The panel's own content and every control targeting it are ordered together
41
41
  * via `order`. Only supported by panels with a `render` component — a panel using static `html`
42
42
  * can't host injected controls, since `dangerouslySetInnerHTML` owns its body's children.
43
+ *
44
+ * @property {string} [tab]
45
+ * Groups this item into a tab within its target panel. Two items share a tab by giving it the
46
+ * same `tab` string (case/whitespace-insensitive — compared kebab-cased); items with no `tab`
47
+ * share one implicit tab, labelled with the panel's own `label`. Tabs only render once this
48
+ * produces more than one distinct group — a panel with zero or one tab renders flat, with no
49
+ * tab UI at all. A tab's position and displayed label both come from whichever member ends up
50
+ * first once its own members are ordered by `order`, so there's no separate tab-order property
51
+ * to set. Like `order`, only respected for controls with a `render` component.
43
52
  */
44
53
 
45
54
  /**
@@ -67,6 +76,11 @@
67
76
  * @property {string} slot
68
77
  * Slot identifier.
69
78
  *
79
+ * @property {string} [tab]
80
+ * Groups the panel's own content into a tab alongside any controls injected into it — see
81
+ * `ControlBreakpointConfig.tab`. Optional even once tabs are active: the panel's own content
82
+ * falls back to an implicit tab labelled with the panel's `label` when omitted.
83
+ *
70
84
  * @property {string} [width]
71
85
  * Panel width.
72
86
  */
@@ -156,8 +170,12 @@
156
170
  * @property {(context: PluginContext) => boolean} [excludeWhen]
157
171
  * Callback to determine if the button should be excluded from rendering.
158
172
  *
159
- * @property {string} [group]
160
- * Button group label for grouping related buttons.
173
+ * @property {{ label: string, slotOrder?: number }} [group]
174
+ * Groups this button with any other button sharing the same `label` (kebab-cased) into one
175
+ * `role="group"` container, rendered as a single item in the slot. `label` is also the
176
+ * group's accessible name (aria-label) — buttons whose labels differ only by case/whitespace
177
+ * still merge into one group. `slotOrder` positions the group itself within the slot (defaults
178
+ * to 0); each button's own breakpoint `order` controls its position *within* the group.
161
179
  *
162
180
  * @property {(context: PluginContext) => boolean} [hiddenWhen]
163
181
  * Callback to determine if the button should be hidden. Sets display: none if true.