@defra/interactive-map 0.0.41-alpha → 0.0.42-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 (65) 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/use-location/dist/esm/im-use-location-plugin.js +1 -1
  18. package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
  19. package/plugins/beta/use-location/src/manifest.js +1 -1
  20. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
  21. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  22. package/plugins/draw/dist/umd/index.js +1 -1
  23. package/plugins/draw/src/api/createNewShape.js +41 -0
  24. package/plugins/draw/src/api/createNewShape.test.js +63 -0
  25. package/plugins/draw/src/api/newLine.js +2 -40
  26. package/plugins/draw/src/api/newPolygon.js +2 -40
  27. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  28. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  29. package/plugins/interact/dist/umd/index.js +1 -1
  30. package/plugins/interact/src/manifest.js +1 -1
  31. package/plugins/map-key/dist/css/index.css +6 -0
  32. package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -1
  33. package/plugins/map-key/dist/umd/im-map-key-plugin.js +1 -1
  34. package/plugins/map-key/src/components/Key/Key.jsx +5 -3
  35. package/plugins/map-key/src/components/Key/Key.module.scss +6 -0
  36. package/plugins/map-key/src/components/Key/KeyItem.jsx +5 -8
  37. package/plugins/map-key/src/components/Key/KeySvg.jsx +21 -22
  38. package/plugins/map-key/src/components/Key/KeySvgLine.jsx +3 -9
  39. package/plugins/map-key/src/components/Key/KeySvgPattern.jsx +2 -7
  40. package/plugins/map-key/src/components/Key/KeySvgPattern.test.jsx +6 -0
  41. package/plugins/map-key/src/components/Key/KeySvgRect.jsx +4 -11
  42. package/plugins/map-key/src/components/Key/KeySvgSymbol.jsx +5 -11
  43. package/plugins/map-key/src/components/Key/KeySvgSymbol.test.jsx +12 -0
  44. package/plugins/map-key/src/components/Key/MapKey.jsx +5 -2
  45. package/plugins/map-key/src/components/Key/MapKey.test.jsx +28 -11
  46. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +25 -23
  47. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +22 -5
  48. package/src/App/components/Panel/Panel.jsx +50 -8
  49. package/src/App/components/Panel/Panel.module.scss +31 -7
  50. package/src/App/components/Panel/Panel.test.jsx +81 -2
  51. package/src/App/components/Tabs/Tabs.jsx +43 -17
  52. package/src/App/components/Tabs/Tabs.module.scss +32 -5
  53. package/src/App/components/Tabs/Tabs.test.jsx +40 -10
  54. package/src/App/renderer/groupByKey.js +28 -0
  55. package/src/App/renderer/groupByKey.test.js +39 -0
  56. package/src/App/renderer/groupIntoTabs.js +42 -0
  57. package/src/App/renderer/groupIntoTabs.test.js +95 -0
  58. package/src/App/renderer/mapButtons.js +74 -122
  59. package/src/App/renderer/mapButtons.test.js +51 -80
  60. package/src/App/renderer/mapControls.js +1 -0
  61. package/src/App/renderer/mapControls.test.js +16 -0
  62. package/src/App/renderer/mapPanels.js +26 -19
  63. package/src/App/renderer/mapPanels.test.js +44 -0
  64. package/src/config/appConfig.js +2 -2
  65. package/src/types.js +20 -2
@@ -0,0 +1,95 @@
1
+ import { groupIntoTabs } from './groupIntoTabs.js'
2
+
3
+ describe('groupIntoTabs', () => {
4
+ it('returns null when there are no items', () => {
5
+ expect(groupIntoTabs({ items: [], fallbackLabel: 'Panel' })).toBeNull()
6
+ })
7
+
8
+ it('returns null when all items share the same tab', () => {
9
+ const items = [
10
+ { id: 'a', order: 0, tab: 'Styles' },
11
+ { id: 'b', order: 0, tab: 'Styles' }
12
+ ]
13
+ expect(groupIntoTabs({ items, fallbackLabel: 'Panel' })).toBeNull()
14
+ })
15
+
16
+ it('returns null when no items have a tab', () => {
17
+ const items = [{ id: 'a', order: 0 }, { id: 'b', order: 0 }]
18
+ expect(groupIntoTabs({ items, fallbackLabel: 'Panel' })).toBeNull()
19
+ })
20
+
21
+ it('partitions items into distinct tabs', () => {
22
+ const items = [
23
+ { id: 'a', order: 0, tab: 'Styles' },
24
+ { id: 'b', order: 0, tab: 'Sizes' }
25
+ ]
26
+ const tabs = groupIntoTabs({ items, fallbackLabel: 'Panel' })
27
+ expect(tabs.map(t => t.name).sort()).toEqual(['Sizes', 'Styles'])
28
+ })
29
+
30
+ it('groups untagged items into one fallback bucket named after fallbackLabel', () => {
31
+ const items = [
32
+ { id: 'a', order: 0, tab: 'Styles' },
33
+ { id: 'b', order: 0 },
34
+ { id: 'c', order: 0 }
35
+ ]
36
+ const tabs = groupIntoTabs({ items, fallbackLabel: 'Map styles' })
37
+ const fallback = tabs.find(t => t.name === 'Map styles')
38
+ expect(fallback.items.map(i => i.id).sort()).toEqual(['b', 'c'])
39
+ })
40
+
41
+ it('merges items whose tab differs only by case/whitespace into one bucket', () => {
42
+ const items = [
43
+ { id: 'a', order: 0, tab: 'Map Size' },
44
+ { id: 'b', order: 1, tab: 'map size' },
45
+ { id: 'c', order: 0, tab: 'Styles' }
46
+ ]
47
+ const tabs = groupIntoTabs({ items, fallbackLabel: 'Panel' })
48
+ expect(tabs).toHaveLength(2)
49
+ const merged = tabs.find(t => t.items.length === 2)
50
+ expect(merged.items.map(i => i.id)).toEqual(expect.arrayContaining(['a', 'b']))
51
+ })
52
+
53
+ it("uses the winning (lowest-order) member's raw tab string as the displayed name", () => {
54
+ const items = [
55
+ { id: 'a', order: 2, tab: 'MAP SIZE' },
56
+ { id: 'b', order: 1, tab: 'map size' }, // lower order wins the label
57
+ { id: 'c', order: 0, tab: 'Styles' }
58
+ ]
59
+ const tabs = groupIntoTabs({ items, fallbackLabel: 'Panel' })
60
+ const merged = tabs.find(t => t.items.length === 2)
61
+ expect(merged.name).toBe('map size')
62
+ })
63
+
64
+ it("derives a tab's order from its first ordered member", () => {
65
+ const items = [
66
+ { id: 'a', order: 5, tab: 'Styles' },
67
+ { id: 'b', order: 1, tab: 'Styles' },
68
+ { id: 'c', order: 0, tab: 'Sizes' }
69
+ ]
70
+ const tabs = groupIntoTabs({ items, fallbackLabel: 'Panel' })
71
+ const stylesTab = tabs.find(t => t.name === 'Styles')
72
+ expect(stylesTab.order).toBe(1)
73
+ })
74
+
75
+ it('orders tabs among themselves using the derived order', () => {
76
+ const items = [
77
+ { id: 'a', order: 2, tab: 'Second' },
78
+ { id: 'b', order: 1, tab: 'First' }
79
+ ]
80
+ const tabs = groupIntoTabs({ items, fallbackLabel: 'Panel' })
81
+ expect(tabs.map(t => t.name)).toEqual(['First', 'Second'])
82
+ })
83
+
84
+ it('orders items within a tab using orderItems', () => {
85
+ const items = [
86
+ { id: 'a', order: 0, tab: 'Styles' },
87
+ { id: 'b', order: 1, tab: 'Styles' },
88
+ { id: 'c', order: 0, tab: 'Sizes' }
89
+ ]
90
+ const tabs = groupIntoTabs({ items, fallbackLabel: 'Panel' })
91
+ const stylesTab = tabs.find(t => t.name === 'Styles')
92
+ // b (order 1) is spliced ahead of the unordered item a
93
+ expect(stylesTab.items.map(i => i.id)).toEqual(['b', 'a'])
94
+ })
95
+ })
@@ -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
  }