@defra/interactive-map 0.0.41-alpha → 0.0.42-fmp-2

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 (77) 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 +4 -2
  15. package/plugins/beta/draw-ol/dist/css/index.css +1 -13
  16. package/plugins/beta/frame/dist/css/index.css +1 -11
  17. package/plugins/beta/map-styles/dist/css/index.css +1 -1
  18. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  19. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  20. package/plugins/beta/scale-bar/dist/css/index.css +1 -31
  21. package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
  22. package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
  23. package/plugins/beta/use-location/src/manifest.js +1 -1
  24. package/plugins/datasets/dist/css/5648.index.css +1 -5
  25. package/plugins/datasets/dist/css/index.css +1 -1
  26. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
  27. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  28. package/plugins/draw/dist/umd/index.js +1 -1
  29. package/plugins/draw/src/api/createNewShape.js +41 -0
  30. package/plugins/draw/src/api/createNewShape.test.js +63 -0
  31. package/plugins/draw/src/api/newLine.js +2 -40
  32. package/plugins/draw/src/api/newPolygon.js +2 -40
  33. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  34. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  35. package/plugins/interact/dist/umd/index.js +1 -1
  36. package/plugins/interact/src/manifest.js +1 -1
  37. package/plugins/map-key/dist/css/index.css +1 -62
  38. package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -1
  39. package/plugins/map-key/dist/umd/im-map-key-plugin.js +1 -1
  40. package/plugins/map-key/src/components/Key/Key.jsx +5 -3
  41. package/plugins/map-key/src/components/Key/Key.module.scss +6 -0
  42. package/plugins/map-key/src/components/Key/KeyItem.jsx +5 -8
  43. package/plugins/map-key/src/components/Key/KeySvg.jsx +21 -22
  44. package/plugins/map-key/src/components/Key/KeySvgLine.jsx +3 -9
  45. package/plugins/map-key/src/components/Key/KeySvgPattern.jsx +2 -7
  46. package/plugins/map-key/src/components/Key/KeySvgPattern.test.jsx +6 -0
  47. package/plugins/map-key/src/components/Key/KeySvgRect.jsx +4 -11
  48. package/plugins/map-key/src/components/Key/KeySvgSymbol.jsx +5 -11
  49. package/plugins/map-key/src/components/Key/KeySvgSymbol.test.jsx +12 -0
  50. package/plugins/map-key/src/components/Key/MapKey.jsx +5 -2
  51. package/plugins/map-key/src/components/Key/MapKey.test.jsx +28 -11
  52. package/plugins/menu/dist/css/index.css +1 -80
  53. package/plugins/search/dist/css/index.css +1 -1
  54. package/providers/beta/esri/dist/css/index.css +1 -34
  55. package/rollup.esm.mjs +2 -1
  56. package/scripts/publish-package.sh +8 -0
  57. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +25 -23
  58. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +22 -5
  59. package/src/App/components/Panel/Panel.jsx +50 -8
  60. package/src/App/components/Panel/Panel.module.scss +31 -7
  61. package/src/App/components/Panel/Panel.test.jsx +81 -2
  62. package/src/App/components/Tabs/Tabs.jsx +43 -17
  63. package/src/App/components/Tabs/Tabs.module.scss +32 -5
  64. package/src/App/components/Tabs/Tabs.test.jsx +40 -10
  65. package/src/App/renderer/groupByKey.js +28 -0
  66. package/src/App/renderer/groupByKey.test.js +39 -0
  67. package/src/App/renderer/groupIntoTabs.js +42 -0
  68. package/src/App/renderer/groupIntoTabs.test.js +95 -0
  69. package/src/App/renderer/mapButtons.js +74 -122
  70. package/src/App/renderer/mapButtons.test.js +51 -80
  71. package/src/App/renderer/mapControls.js +1 -0
  72. package/src/App/renderer/mapControls.test.js +16 -0
  73. package/src/App/renderer/mapPanels.js +26 -19
  74. package/src/App/renderer/mapPanels.test.js +44 -0
  75. package/src/config/appConfig.js +2 -2
  76. package/src/types.js +20 -2
  77. package/webpack.umd.mjs +6 -1
@@ -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.
package/webpack.umd.mjs CHANGED
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'url'
3
3
  import fs from 'fs'
4
4
 
5
5
  import MiniCssExtractPlugin from 'mini-css-extract-plugin'
6
+ import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'
6
7
  import RemoveEmptyScriptsPlugin from 'webpack-remove-empty-scripts'
7
8
  import RemoveFilesPlugin from 'remove-files-webpack-plugin'
8
9
 
@@ -124,7 +125,11 @@ const createUMDConfig = (entryName, entryPath, libraryPath, outDir, isCore = fal
124
125
 
125
126
  optimization: {
126
127
  splitChunks: { chunks: () => false },
127
- removeEmptyChunks: true
128
+ removeEmptyChunks: true,
129
+ // '...' keeps webpack's default JS minimizer (Terser); CssMinimizerPlugin
130
+ // is added alongside it since setting `minimizer` explicitly replaces the
131
+ // defaults otherwise.
132
+ minimizer: ['...', new CssMinimizerPlugin()]
128
133
  }
129
134
  }
130
135
  }