@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.
- package/dist/css/index.css +1 -1
- package/dist/esm/im-core.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/api/button-definition.md +7 -8
- package/docs/api/control-definition.md +8 -0
- package/docs/api/panel-definition.md +5 -0
- package/docs/api/slots.md +24 -1
- package/docs/examples/add-polygons.mdx +8 -2
- package/docs/examples/add-symbols.mdx +8 -2
- package/docs/plugins/datasets.md +23 -15
- package/docs/plugins/map-key.md +147 -0
- package/docs/plugins.md +5 -1
- package/package.json +1 -1
- package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/umd/index.js +1 -1
- package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
- package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
- package/plugins/beta/use-location/src/manifest.js +1 -1
- package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
- package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
- package/plugins/draw/dist/umd/index.js +1 -1
- package/plugins/draw/src/api/createNewShape.js +41 -0
- package/plugins/draw/src/api/createNewShape.test.js +63 -0
- package/plugins/draw/src/api/newLine.js +2 -40
- package/plugins/draw/src/api/newPolygon.js +2 -40
- package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/index.js +1 -1
- package/plugins/interact/src/manifest.js +1 -1
- package/plugins/map-key/dist/css/index.css +6 -0
- package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -1
- package/plugins/map-key/dist/umd/im-map-key-plugin.js +1 -1
- package/plugins/map-key/src/components/Key/Key.jsx +5 -3
- package/plugins/map-key/src/components/Key/Key.module.scss +6 -0
- package/plugins/map-key/src/components/Key/KeyItem.jsx +5 -8
- package/plugins/map-key/src/components/Key/KeySvg.jsx +21 -22
- package/plugins/map-key/src/components/Key/KeySvgLine.jsx +3 -9
- package/plugins/map-key/src/components/Key/KeySvgPattern.jsx +2 -7
- package/plugins/map-key/src/components/Key/KeySvgPattern.test.jsx +6 -0
- package/plugins/map-key/src/components/Key/KeySvgRect.jsx +4 -11
- package/plugins/map-key/src/components/Key/KeySvgSymbol.jsx +5 -11
- package/plugins/map-key/src/components/Key/KeySvgSymbol.test.jsx +12 -0
- package/plugins/map-key/src/components/Key/MapKey.jsx +5 -2
- package/plugins/map-key/src/components/Key/MapKey.test.jsx +28 -11
- package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +25 -23
- package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +22 -5
- package/src/App/components/Panel/Panel.jsx +50 -8
- package/src/App/components/Panel/Panel.module.scss +31 -7
- package/src/App/components/Panel/Panel.test.jsx +81 -2
- package/src/App/components/Tabs/Tabs.jsx +43 -17
- package/src/App/components/Tabs/Tabs.module.scss +32 -5
- package/src/App/components/Tabs/Tabs.test.jsx +40 -10
- package/src/App/renderer/groupByKey.js +28 -0
- package/src/App/renderer/groupByKey.test.js +39 -0
- package/src/App/renderer/groupIntoTabs.js +42 -0
- package/src/App/renderer/groupIntoTabs.test.js +95 -0
- package/src/App/renderer/mapButtons.js +74 -122
- package/src/App/renderer/mapButtons.test.js +51 -80
- package/src/App/renderer/mapControls.js +1 -0
- package/src/App/renderer/mapControls.test.js +16 -0
- package/src/App/renderer/mapPanels.js +26 -19
- package/src/App/renderer/mapPanels.test.js +44 -0
- package/src/config/appConfig.js +2 -2
- 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
|
|
195
|
-
const
|
|
196
|
-
const
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
|
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: {
|
|
289
|
-
b2: { ...baseBtn, desktop: { slot: 'header', order: 2 }, group: {
|
|
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
|
-
|
|
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('
|
|
254
|
+
it('merges group labels that differ only by case/whitespace into one group', () => {
|
|
299
255
|
appState.buttonConfig = ({
|
|
300
|
-
b1: { ...baseBtn, group: {
|
|
301
|
-
b2: { ...baseBtn, group: {
|
|
256
|
+
b1: { ...baseBtn, group: { label: 'Zoom Controls' } },
|
|
257
|
+
b2: { ...baseBtn, group: { label: 'zoom controls' } }
|
|
302
258
|
})
|
|
303
259
|
const result = map()
|
|
304
|
-
expect(result
|
|
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('
|
|
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: {
|
|
310
|
-
b2: { ...baseBtn, group: {
|
|
311
|
-
b3: { ...baseBtn, group: {
|
|
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
|
|
314
|
-
expect(
|
|
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: {
|
|
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('
|
|
329
|
-
appState.buttonConfig = ({ b1: { ...baseBtn, desktop: { slot: 'header', order: 4 }, group: {
|
|
330
|
-
expect(map()[0].order).toBe(
|
|
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
|
|
334
|
-
appState.buttonConfig = ({ b1: { ...baseBtn, desktop: { slot: 'header' }, group: {
|
|
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('
|
|
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: {
|
|
341
|
-
b2: { ...baseBtn,
|
|
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
|
|
345
|
-
expect(
|
|
346
|
-
expect(
|
|
347
|
-
|
|
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', () => {
|
|
@@ -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
|
-
|
|
94
|
-
label={
|
|
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
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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
|
|
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
|
|
127
|
+
return {}
|
|
122
128
|
}
|
|
123
129
|
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
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
|
}
|