@defra/interactive-map 0.0.42-fmp-4 → 0.0.43-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 (138) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/esm/im-shell.js +1 -1
  4. package/dist/umd/im-core.js +1 -1
  5. package/dist/umd/index.js +1 -1
  6. package/docs/api/keyboard-shortcut-definition.md +102 -0
  7. package/docs/api.md +2 -0
  8. package/docs/building-a-plugin.md +1 -0
  9. package/docs/plugins/draw.md +1 -1
  10. package/docs/plugins/plugin-manifest.md +9 -0
  11. package/package.json +1 -1
  12. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
  13. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
  14. package/plugins/beta/frame/src/Frame.jsx +28 -2
  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/datasets/dist/esm/im-datasets-plugin.js +1 -1
  18. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  19. package/plugins/datasets/src/registry/dataset.js +8 -12
  20. package/plugins/datasets/src/registry/dataset.test.js +17 -0
  21. package/plugins/datasets/src/registry/datasetRegistry.js +4 -5
  22. package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -1
  23. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
  24. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
  25. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -1
  26. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
  27. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  28. package/plugins/draw/src/DrawInit.jsx +23 -13
  29. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +15 -0
  30. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +18 -0
  31. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +9 -0
  32. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +12 -1
  33. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +18 -0
  34. package/plugins/draw/src/manifest.js +4 -1
  35. package/plugins/draw/src/manifest.test.js +19 -7
  36. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  37. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  38. package/plugins/interact/dist/umd/index.js +1 -1
  39. package/plugins/interact/src/events.js +6 -2
  40. package/plugins/interact/src/events.test.js +32 -0
  41. package/plugins/interact/src/hooks/useCrossHairVisibility.js +8 -3
  42. package/plugins/interact/src/hooks/useMapItemList.js +41 -12
  43. package/plugins/interact/src/hooks/useMapItemList.test.js +68 -3
  44. package/plugins/interact/src/utils/spatial.js +24 -1
  45. package/plugins/interact/src/utils/spatial.test.js +26 -1
  46. package/plugins/map-key/dist/css/index.css +1 -1
  47. package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -1
  48. package/plugins/map-key/dist/umd/im-map-key-plugin.js +1 -1
  49. package/plugins/map-key/dist/umd/index.js +1 -1
  50. package/plugins/map-key/src/components/Key/Key.jsx +3 -4
  51. package/plugins/map-key/src/components/Key/Key.module.scss +10 -8
  52. package/plugins/map-key/src/components/Key/KeyGroupItem.test.jsx +2 -2
  53. package/plugins/map-key/src/components/Key/KeyItem.jsx +2 -2
  54. package/plugins/map-key/src/components/Key/KeySvg.jsx +9 -7
  55. package/plugins/map-key/src/components/Key/KeySvg.test.jsx +4 -4
  56. package/plugins/map-key/src/components/Key/MapKey.jsx +5 -3
  57. package/plugins/map-key/src/manifest.js +1 -1
  58. package/plugins/map-key/src/utils/groupStyles.js +7 -0
  59. package/plugins/map-key/src/utils/mergeKeyGroupItems.js +12 -0
  60. package/plugins/map-key/src/utils/mergeKeyGroupItems.test.js +53 -0
  61. package/plugins/menu/dist/esm/im-menu-plugin.js +1 -1
  62. package/plugins/menu/dist/umd/im-menu-plugin.js +1 -1
  63. package/plugins/menu/dist/umd/index.js +1 -1
  64. package/plugins/menu/src/initialise/MenuInit.jsx +15 -3
  65. package/plugins/menu/src/initialise/MenuInit.test.jsx +25 -0
  66. package/plugins/menu/src/initialise/createMenu.js +3 -1
  67. package/plugins/menu/src/initialise/createMenu.test.js +20 -5
  68. package/plugins/menu/src/initialise/updateUrl.js +44 -0
  69. package/plugins/menu/src/initialise/updateUrl.test.js +91 -0
  70. package/plugins/menu/src/reducers/menuStateReducer.js +19 -1
  71. package/plugins/menu/src/reducers/menuStateReducer.test.js +53 -20
  72. package/plugins/menu/src/reducers/pluginState.js +1 -6
  73. package/plugins/menu/src/reducers/pluginState.test.js +0 -25
  74. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  75. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  76. package/plugins/search/src/manifest.js +5 -9
  77. package/plugins/search/src/manifest.test.js +5 -0
  78. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  79. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  80. package/providers/beta/openlayers/src/openlayersProvider.js +3 -3
  81. package/providers/beta/openlayers/src/openlayersProvider.test.js +6 -3
  82. package/providers/beta/openlayers/src/utils/queryFeatures.js +72 -0
  83. package/providers/beta/openlayers/src/utils/queryFeatures.test.js +122 -1
  84. package/providers/maplibre/src/defaults.test.js +26 -0
  85. package/src/App/components/CrossHair/CrossHair.jsx +19 -11
  86. package/src/App/components/CrossHair/CrossHair.module.scss +33 -12
  87. package/src/App/components/CrossHair/CrossHair.test.jsx +35 -11
  88. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +4 -1
  89. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +17 -0
  90. package/src/App/components/MapButton/MapButton.jsx +19 -7
  91. package/src/App/components/MapButton/MapButton.test.jsx +42 -4
  92. package/src/App/components/Markers/SymbolMarker.jsx +2 -3
  93. package/src/App/components/Markers/SymbolMarker.test.jsx +8 -9
  94. package/src/App/components/Panel/Panel.jsx +19 -20
  95. package/src/App/components/Panel/Panel.module.scss +5 -0
  96. package/src/App/components/Panel/Panel.test.jsx +69 -4
  97. package/src/App/components/PopupMenu/PopupMenu.jsx +7 -3
  98. package/src/App/components/PopupMenu/PopupMenu.test.jsx +71 -0
  99. package/src/App/components/PopupMenu/usePopupMenu.js +10 -9
  100. package/src/App/components/Viewport/Features.jsx +18 -4
  101. package/src/App/components/Viewport/Features.module.scss +4 -4
  102. package/src/App/components/Viewport/Features.test.jsx +155 -11
  103. package/src/App/components/Viewport/Viewport.jsx +3 -3
  104. package/src/App/controls/keyboardMappings.js +7 -2
  105. package/src/App/controls/keyboardMappings.test.js +21 -0
  106. package/src/App/controls/keyboardShortcuts.js +16 -2
  107. package/src/App/controls/keyboardShortcuts.test.js +21 -0
  108. package/src/App/hooks/useCrossHairAPI.js +29 -1
  109. package/src/App/hooks/useCrossHairAPI.test.js +17 -1
  110. package/src/App/hooks/useFeatureFocus.js +86 -28
  111. package/src/App/hooks/useFeatureFocus.test.js +167 -0
  112. package/src/App/hooks/useFeatureItems.js +1 -1
  113. package/src/App/hooks/useInterfaceAPI.js +2 -1
  114. package/src/App/hooks/useInterfaceAPI.test.js +9 -0
  115. package/src/App/hooks/useKeyboardShortcuts.js +66 -35
  116. package/src/App/hooks/useKeyboardShortcuts.test.js +52 -0
  117. package/src/App/hooks/useModalPanelBehaviour.js +48 -34
  118. package/src/App/hooks/useModalPanelBehaviour.test.js +59 -0
  119. package/src/App/layout/Layout.jsx +4 -2
  120. package/src/App/layout/Layout.test.jsx +21 -0
  121. package/src/App/layout/layout.module.scss +7 -1
  122. package/src/App/registry/keyboardShortcutRegistry.js +2 -2
  123. package/src/App/registry/keyboardShortcutRegistry.test.js +16 -0
  124. package/src/App/renderer/HtmlElementHost.jsx +12 -32
  125. package/src/App/renderer/mapButtons.js +4 -0
  126. package/src/App/renderer/mapButtons.test.js +20 -0
  127. package/src/App/renderer/mapPanels.js +33 -53
  128. package/src/App/renderer/mapPanels.test.js +29 -4
  129. package/src/App/renderer/slotHelpers.js +33 -0
  130. package/src/App/renderer/slotHelpers.test.js +79 -2
  131. package/src/InteractiveMap/InteractiveMap.js +13 -2
  132. package/src/InteractiveMap/InteractiveMap.test.js +33 -0
  133. package/src/config/appConfig.js +2 -2
  134. package/src/types.js +40 -0
  135. package/src/utils/getPanelElementId.js +4 -0
  136. package/src/utils/getPanelElementId.test.js +11 -0
  137. package/src/utils/getPanelRole.js +22 -0
  138. package/src/utils/getPanelRole.test.js +56 -0
@@ -93,6 +93,22 @@ describe('createKeyboardShortcutRegistry', () => {
93
93
  ])
94
94
  })
95
95
 
96
+ test('a mapProviderAgnostic core shortcut is included even when no provider declares support for it', () => {
97
+ jest.resetModules()
98
+ jest.doMock('../controls/keyboardShortcuts.js', () => ({
99
+ coreShortcuts: [
100
+ { id: 'ordinary', description: 'Ordinary' },
101
+ { id: 'agnostic', description: 'Agnostic', mapProviderAgnostic: true }
102
+ ]
103
+ }))
104
+ const registry = require('./keyboardShortcutRegistry.js').createKeyboardShortcutRegistry()
105
+ registry.setProviderSupportedShortcuts([]) // no provider support declared at all
106
+
107
+ expect(registry.getKeyboardShortcuts()).toEqual([
108
+ { id: 'agnostic', description: 'Agnostic', mapProviderAgnostic: true }
109
+ ])
110
+ })
111
+
96
112
  test('two registry instances are fully isolated from each other', () => {
97
113
  // This is the multi-map-on-a-page scenario: a shortcut registered by one map
98
114
  // (e.g. a plugin) must never leak into another map's help panel, and each map's
@@ -2,8 +2,7 @@
2
2
  import React, { useRef, useLayoutEffect, useMemo } from 'react'
3
3
  import { useApp } from '../store/appContext.js'
4
4
  import { Panel } from '../components/Panel/Panel.jsx'
5
- import { resolveTargetSlot, isModeAllowed, isControlVisible, isConsumerHtml } from './slotHelpers.js'
6
- import { allowedSlots } from './slots.js'
5
+ import { resolveTargetSlot, isControlVisible, isConsumerHtml, isPanelSlotEligible, getAllowedModalPanelId } from './slotHelpers.js'
7
6
 
8
7
  /**
9
8
  * Maps slot names to their corresponding layout refs.
@@ -90,28 +89,13 @@ const PersistentPanel = ({ panelId, config, isOpen, openPanelProps, focusOnOpen,
90
89
  const bpConfig = config[breakpoint]
91
90
  const targetSlot = bpConfig ? resolveTargetSlot(bpConfig, breakpoint) : null
92
91
 
93
- // Determine visibility using the same logic as mapPanels
94
- const isVisible = (() => {
95
- // 1. Initial Guard: Basic requirements
96
- if (!isOpen || !bpConfig || !targetSlot) {
97
- return false
98
- }
99
-
100
- // 2. Slot Validation
101
- const isNextToButton = targetSlot.endsWith('-button')
102
- const isSlotAllowed = allowedSlots.panel.includes(targetSlot) || isNextToButton
103
-
104
- if (!isSlotAllowed) {
105
- return false
106
- }
107
-
108
- // 3. Business Logic: Combine remaining conditions into a single "fail" check
109
- const isForbiddenModal = bpConfig.modal && panelId !== allowedModalPanelId
110
- const isForbiddenInline = config.inline === false && !isFullscreen
111
- const isInvalidMode = !isModeAllowed(config, mode)
112
-
113
- return !(isForbiddenModal || isForbiddenInline || isInvalidMode)
114
- })()
92
+ // Same eligibility/modal-exclusivity rules as mapPanels.js (see slotHelpers.js), combined into
93
+ // one boolean since isVisible alone decides whether useDomProjection shows it.
94
+ const isVisible = Boolean(
95
+ isOpen && bpConfig && targetSlot &&
96
+ isPanelSlotEligible(config, { targetSlot, mode, isFullscreen }) &&
97
+ (!bpConfig.modal || panelId === allowedModalPanelId)
98
+ )
115
99
 
116
100
  useDomProjection(panelRootRef, targetSlot, isVisible, layoutRefs, breakpoint)
117
101
 
@@ -181,14 +165,10 @@ export const HtmlElementHost = () => {
181
165
  )
182
166
 
183
167
  // Determine which modal panel is allowed (topmost open modal)
184
- const allowedModalPanelId = useMemo(() => {
185
- const openPanelEntries = Object.entries(openPanels)
186
- const modalPanels = openPanelEntries.filter(([panelId]) => {
187
- const cfg = panelConfig[panelId]?.[breakpoint]
188
- return cfg?.modal
189
- })
190
- return modalPanels.length > 0 ? modalPanels[modalPanels.length - 1][0] : null // NOSONAR - .at() unsupported on Chrome < 92
191
- }, [openPanels, panelConfig, breakpoint])
168
+ const allowedModalPanelId = useMemo(
169
+ () => getAllowedModalPanelId(openPanels, panelConfig, breakpoint),
170
+ [openPanels, panelConfig, breakpoint]
171
+ )
192
172
 
193
173
  if (!htmlPanels.length && !htmlControls.length) {
194
174
  return null
@@ -3,6 +3,7 @@ import { MapButton } from '../components/MapButton/MapButton.jsx'
3
3
  import { allowedSlots } from './slots.js'
4
4
  import { groupByKey } from './groupByKey.js'
5
5
  import { orderItems } from './orderItems.js'
6
+ import { classifyPanel, getPanelRole } from '../../utils/getPanelRole.js'
6
7
  import { logger } from '../../services/logger.js'
7
8
 
8
9
  function getMatchingButtons ({ appState, buttonConfig, slot, evaluateProp }) {
@@ -118,6 +119,8 @@ function SlotButton ({ buttonId, config, appState, appConfig, evaluateProp }) {
118
119
  const bpConfig = config[appState.breakpoint] ?? {}
119
120
  const handleClick = createButtonClickHandler(config, appState, evaluateProp)
120
121
  const isPanelOpen = !!(config.panelId && appState.openPanels[config.panelId])
122
+ const panelBpConfig = config.panelId ? appState.panelConfig?.[config.panelId]?.[appState.breakpoint] : undefined
123
+ const panelRole = panelBpConfig ? getPanelRole(classifyPanel(panelBpConfig)) : undefined
121
124
 
122
125
  return (
123
126
  <MapButton
@@ -133,6 +136,7 @@ function SlotButton ({ buttonId, config, appState, appConfig, evaluateProp }) {
133
136
  isPressed={(config.isPressed !== undefined || config.pressedWhen) ? appState.pressedButtons.has(buttonId) : undefined}
134
137
  isExpanded={(config.isExpanded !== undefined || config.expandedWhen) ? appState.expandedButtons.has(buttonId) : undefined}
135
138
  isPanelOpen={isPanelOpen}
139
+ panelRole={panelRole}
136
140
  onClick={handleClick}
137
141
  panelId={config.panelId}
138
142
  menuItems={config.menuItems}
@@ -170,6 +170,26 @@ describe('mapButtons module', () => {
170
170
  expect(result.props).toMatchObject({ isDisabled: true, isHidden: true, isPressed: true, isExpanded: true })
171
171
  })
172
172
 
173
+ it.each([
174
+ [{ slot: 'right-top', open: false }, 'dialog'],
175
+ [{ slot: 'right-top', open: false, dismissible: false }, 'region'],
176
+ [{ slot: 'side', open: true }, 'complementary']
177
+ ])('derives panelRole %j -> %s from the target panel\'s breakpoint config', (panelBpConfig, expectedRole) => {
178
+ const state = { ...appState, panelConfig: { p1: { desktop: panelBpConfig } } }
179
+ const result = render({ ...baseBtn, panelId: 'p1' }, state)
180
+ expect(result.props.panelRole).toBe(expectedRole)
181
+ })
182
+
183
+ it('leaves panelRole undefined when the button has no panelId', () => {
184
+ const result = render(baseBtn)
185
+ expect(result.props.panelRole).toBeUndefined()
186
+ })
187
+
188
+ it('leaves panelRole undefined when the target panel has no config at this breakpoint', () => {
189
+ const result = render({ ...baseBtn, panelId: 'missing' })
190
+ expect(result.props.panelRole).toBeUndefined()
191
+ })
192
+
173
193
  it('uses empty object fallback for missing breakpoint config', () => {
174
194
  const result = render(baseBtn, { ...appState, breakpoint: 'mobile' })
175
195
  expect(result.props.showLabel).toBe(true)
@@ -2,8 +2,7 @@
2
2
  import React from 'react'
3
3
  import { withPluginContexts } from './pluginWrapper.js'
4
4
  import { Panel } from '../components/Panel/Panel.jsx'
5
- import { allowedSlots } from './slots.js'
6
- import { resolveTargetSlot, isModeAllowed, isConsumerHtml } from './slotHelpers.js'
5
+ import { resolveTargetSlot, isConsumerHtml, isPanelSlotEligible, getAllowedModalPanelId } from './slotHelpers.js'
7
6
  import { mapControls } from './mapControls.js'
8
7
  import { orderItems } from './orderItems.js'
9
8
  import { groupIntoTabs } from './groupIntoTabs.js'
@@ -11,69 +10,49 @@ import { stringToKebab } from '../../utils/stringToKebab.js'
11
10
  import { logger } from '../../services/logger.js'
12
11
 
13
12
  /**
14
- * Determines whether a panel should be rendered in the given slot.
15
- * Checks slot eligibility, mode restrictions, inline/fullscreen constraints,
16
- * and ensures only the topmost modal panel is shown.
13
+ * Maps every configured panel eligible for the given layout slot to a renderable entry — open or
14
+ * closed alike, so a button's aria-controls id always resolves to a stable, permanently-mounted
15
+ * <Panel> (hidden when closed). buildPanelBody only runs while open, so nothing expensive mounts
16
+ * before then.
17
17
  */
18
- const isPanelVisible = (panelId, config, bpConfig, { targetSlot, slot, mode, isFullscreen, allowedModalPanelId }) => {
19
- const isNextToButton = targetSlot.endsWith('-button')
20
- if (!allowedSlots.panel.includes(targetSlot) && !isNextToButton) {
21
- return false
18
+ // Consumer HTML panels are managed by HtmlElementHost; the rest need a breakpoint config and to
19
+ // be eligible for this slot. Returns null (skip) or the pieces the caller needs.
20
+ const getEligiblePanelConfig = (panelId, panelConfig, breakpoint, { slot, mode, isFullscreen }) => {
21
+ const config = panelConfig[panelId]
22
+ if (!config || isConsumerHtml(config)) {
23
+ return null
22
24
  }
23
- if (!isModeAllowed(config, mode)) {
24
- return false
25
+ const bpConfig = config[breakpoint]
26
+ if (!bpConfig) {
27
+ return null
25
28
  }
26
- if (config.inline === false && !isFullscreen) {
27
- return false
29
+ const targetSlot = resolveTargetSlot(bpConfig, breakpoint)
30
+ if (!isPanelSlotEligible(config, { targetSlot, slot, mode, isFullscreen })) {
31
+ return null
28
32
  }
29
- if (targetSlot !== slot) {
30
- return false
31
- }
32
- if (bpConfig.modal && panelId !== allowedModalPanelId) {
33
- return false
34
- }
35
- return true
33
+ return { config, bpConfig }
36
34
  }
37
35
 
38
- /**
39
- * Maps open panels to renderable entries for a given layout slot.
40
- * Filters panels by slot, breakpoint, mode, and modal state, then wraps
41
- * each panel's render function with the appropriate plugin contexts.
42
- */
36
+ // A losing modal panel (see getAllowedModalPanelId) still gets a shell — it's just not open.
37
+ const resolveIsOpen = (openEntry, bpConfig, panelId, allowedModalPanelId) =>
38
+ Boolean(openEntry) && (!bpConfig.modal || panelId === allowedModalPanelId)
39
+
43
40
  export function mapPanels ({ slot, appState, evaluateProp }) {
44
41
  const { breakpoint, pluginRegistry, panelConfig, mode, openPanels } = appState
45
42
 
46
- const openPanelEntries = Object.entries(openPanels)
47
-
48
- const modalPanels = openPanelEntries.filter(([panelId]) => {
49
- const cfg = panelConfig[panelId]?.[breakpoint]
50
- return cfg?.modal
51
- })
52
- const allowedModalPanelId = modalPanels.length > 0 ? modalPanels[modalPanels.length - 1][0] : null // NOSONAR, .at() is only Chrome 90+
53
-
54
- return openPanelEntries.map(([panelId, { props, focusOnOpen }]) => {
55
- const config = panelConfig[panelId]
56
- if (!config) {
57
- return null
58
- }
59
-
60
- // Consumer HTML panels are managed by HtmlElementHost
61
- if (isConsumerHtml(config)) {
62
- return null
63
- }
43
+ // Only the most-recently-opened modal panel is ever actually shown — see isOpen below.
44
+ const allowedModalPanelId = getAllowedModalPanelId(openPanels, panelConfig, breakpoint)
64
45
 
65
- const bpConfig = config[breakpoint]
66
- if (!bpConfig) {
46
+ return Object.keys(panelConfig).map((panelId) => {
47
+ const eligible = getEligiblePanelConfig(panelId, panelConfig, breakpoint, { slot, mode, isFullscreen: appState.isFullscreen })
48
+ if (!eligible) {
67
49
  return null
68
50
  }
51
+ const { config, bpConfig } = eligible
69
52
 
70
- const targetSlot = resolveTargetSlot(bpConfig, breakpoint)
71
-
72
- if (!isPanelVisible(panelId, config, bpConfig, {
73
- targetSlot, slot, mode, isFullscreen: appState.isFullscreen, allowedModalPanelId
74
- })) {
75
- return null
76
- }
53
+ const openEntry = openPanels[panelId]
54
+ const isOpen = resolveIsOpen(openEntry, bpConfig, panelId, allowedModalPanelId)
55
+ const { props = {}, focusOnOpen } = openEntry ?? {}
77
56
 
78
57
  const plugin = pluginRegistry.registeredPlugins.find(p => p.id === config.pluginId)
79
58
  const pluginId = plugin?.id
@@ -92,7 +71,8 @@ export function mapPanels ({ slot, appState, evaluateProp }) {
92
71
  panelConfig={config}
93
72
  props={props}
94
73
  focusOnOpen={focusOnOpen}
95
- {...buildPanelBody({ panelId, config, bpConfig, props, plugin, pluginId, html, label, appState, evaluateProp })}
74
+ isOpen={isOpen}
75
+ {...(isOpen ? buildPanelBody({ panelId, config, bpConfig, props, plugin, pluginId, html, label, appState, evaluateProp }) : {})}
96
76
  label={label}
97
77
  html={html}
98
78
  />
@@ -40,8 +40,25 @@ describe('mapPanels', () => {
40
40
  defaultAppState.panelConfig = ({ p1: baseConfig })
41
41
  })
42
42
 
43
- it('returns empty array when no panels are open', () => {
44
- expect(map({ ...defaultAppState, openPanels: {} })).toEqual([])
43
+ it('renders a closed shell (not nothing) for an eligible panel that is not open', () => {
44
+ const result = map({ ...defaultAppState, openPanels: {} })
45
+ expect(result).toHaveLength(1)
46
+ expect(result[0]).toMatchObject({ id: 'p1', type: 'panel', order: 1 })
47
+ expect(result[0].element.props.isOpen).toBe(false)
48
+ })
49
+
50
+ it('does not build a closed panel\'s body before it opens', () => {
51
+ const renderFn = jest.fn(() => <div>child</div>)
52
+ defaultAppState.panelConfig = ({ p1: { ...baseConfig, render: renderFn } })
53
+ const result = map({ ...defaultAppState, openPanels: {} })
54
+ expect(mapControls).not.toHaveBeenCalled()
55
+ expect(withPluginContexts).not.toHaveBeenCalled()
56
+ expect(result[0].element.props.items).toBeUndefined()
57
+ expect(result[0].element.props.tabs).toBeUndefined()
58
+ })
59
+
60
+ it('returns an empty array only when there is nothing eligible for the slot at all', () => {
61
+ expect(map({ ...defaultAppState, panelConfig: {} })).toEqual([])
45
62
  })
46
63
 
47
64
  it('skips panel if config is missing', () => {
@@ -49,6 +66,11 @@ describe('mapPanels', () => {
49
66
  expect(map()).toEqual([])
50
67
  })
51
68
 
69
+ it('skips a registered panelId whose config is falsy', () => {
70
+ defaultAppState.panelConfig = ({ p1: null })
71
+ expect(map()).toEqual([])
72
+ })
73
+
52
74
  it('skips panel if breakpoint config is missing', () => {
53
75
  defaultAppState.panelConfig = ({ p1: {} })
54
76
  expect(map()).toEqual([])
@@ -92,7 +114,7 @@ describe('mapPanels', () => {
92
114
  expect(result).toEqual([])
93
115
  })
94
116
 
95
- it('only allows last opened modal panel', () => {
117
+ it('renders both modal panels\' shells but only marks the last-opened one as open', () => {
96
118
  defaultAppState.panelConfig = ({
97
119
  p1: { desktop: { modal: true }, includeModes: ['view'] },
98
120
  p2: { desktop: { modal: true }, includeModes: ['view'] }
@@ -101,7 +123,10 @@ describe('mapPanels', () => {
101
123
  ...defaultAppState,
102
124
  openPanels: { p1: { props: {} }, p2: { props: {} } }
103
125
  }
104
- expect(map(state, 'modal').map(r => r.id)).toEqual(['p2'])
126
+ const result = map(state, 'modal')
127
+ expect(result.map(r => r.id).sort()).toEqual(['p1', 'p2'])
128
+ expect(result.find(r => r.id === 'p1').element.props.isOpen).toBe(false)
129
+ expect(result.find(r => r.id === 'p2').element.props.isOpen).toBe(true)
105
130
  })
106
131
 
107
132
  it('wraps render function with plugin context', () => {
@@ -60,3 +60,36 @@ export const isControlVisible = (control, { breakpoint, mode, isFullscreen }) =>
60
60
  export const isConsumerHtml = (config) => {
61
61
  return typeof config.html === 'string' && !config.pluginId
62
62
  }
63
+
64
+ /**
65
+ * Whether a panel is eligible for a target slot (slot type, mode, inline/fullscreen) —
66
+ * independent of open state and modal exclusivity (see getAllowedModalPanelId). `slot`, if
67
+ * given, also requires an exact match to the panel's resolved targetSlot.
68
+ */
69
+ export const isPanelSlotEligible = (config, { targetSlot, slot, mode, isFullscreen }) => {
70
+ const isNextToButton = targetSlot.endsWith('-button')
71
+ if (!allowedSlots.panel.includes(targetSlot) && !isNextToButton) {
72
+ return false
73
+ }
74
+ if (!isModeAllowed(config, mode)) {
75
+ return false
76
+ }
77
+ if (config.inline === false && !isFullscreen) {
78
+ return false
79
+ }
80
+ if (slot !== undefined && targetSlot !== slot) {
81
+ return false
82
+ }
83
+ return true
84
+ }
85
+
86
+ // The id of the most-recently-opened modal panel — the only one actually allowed to show.
87
+ export const getAllowedModalPanelId = (openPanels, panelConfig, breakpoint) => {
88
+ const openModalPanelIds = Object.keys(openPanels).filter(panelId => panelConfig[panelId]?.[breakpoint]?.modal)
89
+ return openModalPanelIds.length > 0 ? openModalPanelIds[openModalPanelIds.length - 1] : null // NOSONAR, .at() is only Chrome 90+
90
+ }
91
+
92
+ // Whether any modal-configured panel is open — drives the modal backdrop's visibility.
93
+ export const hasOpenModalPanel = (openPanels, panelConfig, breakpoint) => {
94
+ return getAllowedModalPanelId(openPanels, panelConfig, breakpoint) !== null
95
+ }
@@ -1,6 +1,6 @@
1
- import { resolveTargetSlot, isModeAllowed, isControlVisible, isConsumerHtml } from './slotHelpers.js'
1
+ import { resolveTargetSlot, isModeAllowed, isControlVisible, isConsumerHtml, isPanelSlotEligible, getAllowedModalPanelId, hasOpenModalPanel } from './slotHelpers.js'
2
2
 
3
- jest.mock('./slots.js', () => ({ allowedSlots: { control: ['inset', 'banner', 'actions'] } }))
3
+ jest.mock('./slots.js', () => ({ allowedSlots: { control: ['inset', 'banner', 'actions'], panel: ['header', 'modal', 'left-top'] } }))
4
4
 
5
5
  describe('resolveTargetSlot', () => {
6
6
  it('returns modal for modal panels', () => {
@@ -85,3 +85,80 @@ describe('isConsumerHtml', () => {
85
85
  expect(isConsumerHtml({ render: () => {} })).toBe(false)
86
86
  })
87
87
  })
88
+
89
+ describe('isPanelSlotEligible', () => {
90
+ const base = { includeModes: ['view'] }
91
+ const ctx = { targetSlot: 'header', mode: 'view', isFullscreen: false }
92
+
93
+ it('returns true for a panel in an allowed slot', () => {
94
+ expect(isPanelSlotEligible(base, ctx)).toBe(true)
95
+ })
96
+
97
+ it('returns false when the target slot is not an allowed panel slot', () => {
98
+ expect(isPanelSlotEligible(base, { ...ctx, targetSlot: 'invalid' })).toBe(false)
99
+ })
100
+
101
+ it('allows a target slot next to a button even though it is not a named panel slot', () => {
102
+ expect(isPanelSlotEligible(base, { ...ctx, targetSlot: 'my-button-button' })).toBe(true)
103
+ })
104
+
105
+ it('returns false when mode is not allowed', () => {
106
+ expect(isPanelSlotEligible({ includeModes: ['edit'] }, ctx)).toBe(false)
107
+ })
108
+
109
+ it('returns false when inline:false and not fullscreen', () => {
110
+ expect(isPanelSlotEligible({ ...base, inline: false }, ctx)).toBe(false)
111
+ })
112
+
113
+ it('returns true when inline:false and fullscreen', () => {
114
+ expect(isPanelSlotEligible({ ...base, inline: false }, { ...ctx, isFullscreen: true })).toBe(true)
115
+ })
116
+
117
+ it('returns false when a specific requested slot is given and the target slot does not match it', () => {
118
+ expect(isPanelSlotEligible(base, { ...ctx, slot: 'left-top' })).toBe(false)
119
+ })
120
+
121
+ it('returns true when a specific requested slot is given and matches the target slot', () => {
122
+ expect(isPanelSlotEligible(base, { ...ctx, slot: 'header' })).toBe(true)
123
+ })
124
+
125
+ it('skips the requested-slot check entirely when slot is omitted (HtmlElementHost usage)', () => {
126
+ expect(isPanelSlotEligible(base, { targetSlot: 'header', mode: 'view', isFullscreen: false })).toBe(true)
127
+ })
128
+ })
129
+
130
+ describe('getAllowedModalPanelId', () => {
131
+ const panelConfig = {
132
+ p1: { desktop: { modal: true } },
133
+ p2: { desktop: { modal: true } },
134
+ p3: { desktop: {} }
135
+ }
136
+
137
+ it('returns null when no modal panel is open', () => {
138
+ expect(getAllowedModalPanelId({ p3: { props: {} } }, panelConfig, 'desktop')).toBeNull()
139
+ })
140
+
141
+ it('returns the only open modal panel', () => {
142
+ expect(getAllowedModalPanelId({ p1: { props: {} } }, panelConfig, 'desktop')).toBe('p1')
143
+ })
144
+
145
+ it('returns the most recently opened modal panel when more than one is open', () => {
146
+ expect(getAllowedModalPanelId({ p1: { props: {} }, p2: { props: {} } }, panelConfig, 'desktop')).toBe('p2')
147
+ })
148
+
149
+ it('returns null when there are no open panels at all', () => {
150
+ expect(getAllowedModalPanelId({}, panelConfig, 'desktop')).toBeNull()
151
+ })
152
+ })
153
+
154
+ describe('hasOpenModalPanel', () => {
155
+ const panelConfig = { p1: { desktop: { modal: true } }, p2: { desktop: {} } }
156
+
157
+ it('returns false when no modal panel is open', () => {
158
+ expect(hasOpenModalPanel({ p2: { props: {} } }, panelConfig, 'desktop')).toBe(false)
159
+ })
160
+
161
+ it('returns true when a modal panel is open', () => {
162
+ expect(hasOpenModalPanel({ p1: { props: {} } }, panelConfig, 'desktop')).toBe(true)
163
+ })
164
+ })
@@ -461,12 +461,23 @@ export default class InteractiveMap {
461
461
  * Focus is moved to the panel by default. Set `focus: false` in options to
462
462
  * suppress this — useful when showing a panel and you want focus to remain on the button.
463
463
  *
464
+ * When the panel is later closed, focus returns to `triggeringElement` if one is given or
465
+ * can be inferred, falling back to the map viewport otherwise. If `triggeringElement` isn't
466
+ * passed explicitly, whatever currently has real DOM focus is captured automatically (e.g. a
467
+ * selected item in the map features list) — this only needs to be passed explicitly when the
468
+ * panel is opened from something that doesn't hold focus at the time (e.g a timer, or a
469
+ * genuine mouse click, which doesn't reliably focus its target in every browser).
470
+ *
464
471
  * @param {string} id - Panel identifier to show.
465
472
  * @param {object} [options]
466
473
  * @param {boolean} [options.focus=true] - Whether to move focus to the panel.
474
+ * @param {HTMLElement} [options.triggeringElement] - Element to return focus to on close.
475
+ * Defaults to the currently focused element, if any.
467
476
  */
468
- showPanel (id, { focus = true } = {}) {
469
- this.eventBus.emit(events.APP_SHOW_PANEL, { id, focus })
477
+ showPanel (id, { focus = true, triggeringElement } = {}) {
478
+ const resolvedTriggeringElement = triggeringElement ??
479
+ (document.activeElement && document.activeElement !== document.body ? document.activeElement : undefined)
480
+ this.eventBus.emit(events.APP_SHOW_PANEL, { id, focus, triggeringElement: resolvedTriggeringElement })
470
481
  }
471
482
 
472
483
  /**
@@ -580,6 +580,39 @@ describe('InteractiveMap — Public API Methods', () => {
580
580
  expect(map.eventBus.emit).toHaveBeenCalledWith('app:togglebuttonstate', { id: 'btn-1', prop: 'disabled', value: true })
581
581
  })
582
582
 
583
+ describe('showPanel — triggeringElement', () => {
584
+ afterEach(() => { document.body.innerHTML = '' })
585
+
586
+ it('passes an explicit triggeringElement through unchanged', () => {
587
+ const el = document.createElement('button')
588
+ map.showPanel('panel1', { triggeringElement: el })
589
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:showpanel', { id: 'panel1', focus: true, triggeringElement: el })
590
+ })
591
+
592
+ it('auto-captures the currently focused element when none is given', () => {
593
+ const el = document.createElement('button')
594
+ document.body.appendChild(el)
595
+ el.focus()
596
+ map.showPanel('panel1')
597
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:showpanel', { id: 'panel1', focus: true, triggeringElement: el })
598
+ })
599
+
600
+ it('does not fall back to document.body as a triggeringElement', () => {
601
+ document.body.focus?.() // no-op in jsdom, but document.activeElement defaults to body regardless
602
+ map.showPanel('panel1')
603
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:showpanel', { id: 'panel1', focus: true, triggeringElement: undefined })
604
+ })
605
+
606
+ it('an explicit triggeringElement takes priority over the currently focused element', () => {
607
+ const focused = document.createElement('button')
608
+ document.body.appendChild(focused)
609
+ focused.focus()
610
+ const explicit = document.createElement('button')
611
+ map.showPanel('panel1', { triggeringElement: explicit })
612
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:showpanel', { id: 'panel1', focus: true, triggeringElement: explicit })
613
+ })
614
+ })
615
+
583
616
  it('setContinueEnabled enables and disables the journey continue button', () => {
584
617
  map.setContinueEnabled(true)
585
618
  expect(map.eventBus.emit).toHaveBeenCalledWith('app:togglebuttonstate', { id: 'journeyContinue', prop: 'disabled', value: false })
@@ -98,7 +98,7 @@ export const defaultAppConfig = {
98
98
  desktop: buttonSlots
99
99
  }, {
100
100
  id: 'moveControls',
101
- label: 'Move and zoom controls',
101
+ label: 'Map controls',
102
102
  iconId: 'move',
103
103
  keepFocus: true,
104
104
  isExpanded: false,
@@ -157,7 +157,7 @@ export const defaultAppConfig = {
157
157
  svgContent: '<path d="m6 9 6 6 6-6"/>'
158
158
  }, {
159
159
  id: 'move',
160
- svgContent: '<path d="M12 2v20"/><path d="m15 19-3 3-3-3"/><path d="m19 9 3 3-3 3"/><path d="M2 12h20"/><path d="m5 9-3 3 3 3"/><path d="m9 5 3-3 3 3"/>'
160
+ svgContent: '<path d="M11.146 15.854a1.207 1.207 0 0 1 1.708 0l1.56 1.56A2 2 0 0 1 15 18.828V21a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1v-2.172a2 2 0 0 1 .586-1.414z"/><path d="M18.828 15a2 2 0 0 1-1.414-.586l-1.56-1.56a1.207 1.207 0 0 1 0-1.708l1.56-1.56A2 2 0 0 1 18.828 9H21a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1z"/><path d="M6.586 14.414A2 2 0 0 1 5.172 15H3a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h2.172a2 2 0 0 1 1.414.586l1.56 1.56a1.207 1.207 0 0 1 0 1.708z"/><path d="M9 3a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2.172a2 2 0 0 1-.586 1.414l-1.56 1.56a1.207 1.207 0 0 1-1.708 0l-1.56-1.56A2 2 0 0 1 9 5.172z"/>'
161
161
  }, {
162
162
  id: 'precision',
163
163
  svgContent: '<circle cx="12" cy="12" r="10"/><line x1="22" x2="18" y1="12" y2="12"/><line x1="6" x2="2" y1="12" y2="12"/><line x1="12" x2="12" y1="6" y2="2"/><line x1="12" x2="12" y1="22" y2="18"/>'
package/src/types.js CHANGED
@@ -629,6 +629,43 @@
629
629
  * Raw SVG content (without the outer SVG tag).
630
630
  */
631
631
 
632
+ /**
633
+ * Describes a single row in the keyboard shortcuts help panel (opened via Shift+?).
634
+ * Plugins register these via `PluginManifest.keyboardShortcuts`; core (built-in)
635
+ * shortcuts share this same shape internally.
636
+ *
637
+ * @typedef {Object} KeyboardShortcutDefinition
638
+ *
639
+ * @property {string} command
640
+ * HTML string describing the key combination, rendered as markup — wrap keys in
641
+ * `<kbd>` tags, e.g. `'<kbd>Shift</kbd> + <kbd>K</kbd>'`. If it uses the Alt key,
642
+ * label it per platform (macOS calls it Option, not Alt) — see the shared `isMac()`
643
+ * helper and its use in the app's own core shortcuts.
644
+ *
645
+ * @property {'viewport' | 'listbox' | 'global'} [context='viewport']
646
+ * Which help-panel context this shortcut applies to. Used only to choose the panel's
647
+ * default open tab — it doesn't affect whether the row is shown.
648
+ *
649
+ * @property {string} [group='Navigate']
650
+ * Tab label the shortcut is grouped under in the help panel. Shortcuts sharing a group
651
+ * (case/whitespace-insensitive) appear together under one tab; if every visible shortcut
652
+ * shares one group, the panel renders as a flat list with no tabs.
653
+ *
654
+ * @property {string} id
655
+ * Unique shortcut identifier.
656
+ *
657
+ * @property {string[]} [requiredConfig]
658
+ * App config keys that must all be truthy for the shortcut to appear in the help panel.
659
+ *
660
+ * @property {string} title
661
+ * Accessible title shown in the help panel.
662
+ *
663
+ * @property {boolean} [visuallyHidden=false]
664
+ * When true, the row is hidden from sighted users but stays in the DOM and remains
665
+ * discoverable by assistive technology. Use for a shortcut with no visual affordance to
666
+ * discover it by otherwise — e.g. one whose only effect is a screen reader announcement.
667
+ */
668
+
632
669
  /**
633
670
  * Manifest defining a plugin's buttons, panels, controls, API methods, and state.
634
671
  *
@@ -649,6 +686,9 @@
649
686
  * @property {ComponentType} [InitComponent]
650
687
  * Initialization component.
651
688
  *
689
+ * @property {KeyboardShortcutDefinition[]} [keyboardShortcuts]
690
+ * Keyboard shortcut definitions shown in the keyboard shortcuts help panel.
691
+ *
652
692
  * @property {PanelDefinition[]} [panels]
653
693
  * Panel definitions.
654
694
  *
@@ -0,0 +1,4 @@
1
+ import { stringToKebab } from './stringToKebab.js'
2
+
3
+ // Shared by MapButton (aria-controls) and Panel (its own root id) so they can't drift apart.
4
+ export const getPanelElementId = (idPrefix, panelId) => `${idPrefix}-panel-${stringToKebab(panelId)}`
@@ -0,0 +1,11 @@
1
+ import { getPanelElementId } from './getPanelElementId.js'
2
+
3
+ describe('getPanelElementId', () => {
4
+ it('builds the id from the idPrefix and kebab-cased panelId', () => {
5
+ expect(getPanelElementId('prefix', 'Settings')).toBe('prefix-panel-settings')
6
+ })
7
+
8
+ it('kebab-cases a camelCase panelId', () => {
9
+ expect(getPanelElementId('map1', 'layerOptions')).toBe('map1-panel-layer-options')
10
+ })
11
+ })
@@ -0,0 +1,22 @@
1
+ // Shared panel dialog/modality classification, used by Panel.jsx (to render the panel's
2
+ // own role/aria-modal) and MapButton.jsx (to decide a trigger button's aria-haspopup) so
3
+ // the two can't drift apart.
4
+
5
+ // WCAG requires a modal to always be dismissible, so modal:true forces it regardless of config.
6
+ export const classifyPanel = (bpConfig) => {
7
+ const isModal = bpConfig.modal === true
8
+ const isAside = bpConfig.slot === 'side' && bpConfig.open && !isModal
9
+ const isDismissible = isModal || bpConfig.dismissible !== false
10
+ const isDialog = !isAside && isDismissible
11
+ return { isModal, isAside, isDismissible, isDialog }
12
+ }
13
+
14
+ export const getPanelRole = ({ isDialog, isDismissible }) => {
15
+ if (isDialog) {
16
+ return 'dialog'
17
+ }
18
+ if (isDismissible) {
19
+ return 'complementary'
20
+ }
21
+ return 'region'
22
+ }