@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
@@ -154,6 +154,14 @@ describe('useFeatureFocus — onBlur', () => {
154
154
  const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS }))
155
155
  expect(() => act(() => result.current.onBlur())).not.toThrow()
156
156
  })
157
+
158
+ // The "moving between sibling options shouldn't count as a real blur/focus" behavior is
159
+ // driven by an internal isInternalFocusMoveRef, set only while focusOption()'s own .focus()
160
+ // call is synchronously in flight — not observable by calling onFocus/onBlur directly here,
161
+ // since that requires a real DOM focus transition bubbling through React. See the
162
+ // "Features + useFeatureFocus — roving tabindex real focus events" suite in Features.test.jsx,
163
+ // which renders the real markup and is the only level this can be faithfully exercised at
164
+ // (and is where the regression this guards against was actually found).
157
165
  })
158
166
 
159
167
  // ─── useFeatureFocus — keydown listener lifecycle ────────────────────────────
@@ -348,6 +356,70 @@ describe('useFeatureFocus — ArrowUp navigation', () => {
348
356
  })
349
357
  })
350
358
 
359
+ // ─── useFeatureFocus — Home/End navigation ───────────────────────────────────
360
+
361
+ describe('useFeatureFocus — Home/End navigation', () => {
362
+ const setup = () => {
363
+ const refs = makeRefs()
364
+ const el = refs.featuresRef.current
365
+ document.body.appendChild(el)
366
+ const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
367
+ return { result, el, unmount }
368
+ }
369
+
370
+ it('jumps to the first item on Home from anywhere in the list', () => {
371
+ const { result, el, unmount } = setup()
372
+ act(() => result.current.onFocus())
373
+ fireKey(el, 'ArrowDown')
374
+ fireKey(el, 'Home')
375
+ expect(result.current.activeFeatureId).toBe('a')
376
+ unmount(); el.remove()
377
+ })
378
+
379
+ it('jumps to the last item on End from anywhere in the list', () => {
380
+ const { result, el, unmount } = setup()
381
+ act(() => result.current.onFocus())
382
+ fireKey(el, 'End')
383
+ expect(result.current.activeFeatureId).toBe('c')
384
+ unmount(); el.remove()
385
+ })
386
+
387
+ it('does nothing when items is empty', () => {
388
+ const refs = makeRefs()
389
+ const el = refs.featuresRef.current
390
+ document.body.appendChild(el)
391
+ const { result, unmount } = renderHook(() => useFeatureFocus(refs))
392
+ fireKey(el, 'Home')
393
+ fireKey(el, 'End')
394
+ expect(result.current.activeFeatureId).toBeNull()
395
+ unmount(); el.remove()
396
+ })
397
+
398
+ it('stops propagation on Home/End so the viewport keyboard handler does not also handle it', () => {
399
+ const { el, unmount } = setup()
400
+ for (const key of ['Home', 'End']) {
401
+ const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
402
+ const stopSpy = jest.spyOn(event, 'stopPropagation')
403
+ act(() => el.dispatchEvent(event))
404
+ expect(stopSpy).toHaveBeenCalled()
405
+ }
406
+ unmount()
407
+ })
408
+
409
+ it('emits map:setactivefeature with the boundary id', () => {
410
+ const eb = makeEventBus()
411
+ const refs = makeRefs()
412
+ const el = refs.featuresRef.current
413
+ document.body.appendChild(el)
414
+ const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS, eventBus: eb }))
415
+ act(() => result.current.onFocus())
416
+ eb.emit.mockClear()
417
+ fireKey(el, 'End')
418
+ expect(eb.emit).toHaveBeenCalledWith(SET_ACTIVE, { id: 'c' })
419
+ unmount(); el.remove()
420
+ })
421
+ })
422
+
351
423
  // ─── useFeatureFocus — eventBus integration ───────────────────────────────────
352
424
 
353
425
  describe('useFeatureFocus — eventBus: map:setactivefeature listener', () => {
@@ -633,3 +705,98 @@ describe('useFeatureFocus — items change while focused', () => {
633
705
  unmount()
634
706
  })
635
707
  })
708
+
709
+ // ─── useFeatureFocus — tabbableId (roving tabindex resting position) ─────────
710
+
711
+ describe('useFeatureFocus — tabbableId', () => {
712
+ it('is null when items is empty', () => {
713
+ const { result } = renderHook(() => useFeatureFocus(makeRefs()))
714
+ expect(result.current.tabbableId).toBeNull()
715
+ })
716
+
717
+ it('defaults to the first item before the list has ever been focused', () => {
718
+ const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS }))
719
+ expect(result.current.tabbableId).toBe('a')
720
+ })
721
+
722
+ // Unlike onFocus's own resolution, tabbableId deliberately ignores selection — "prefer the
723
+ // selected item" is a real-Tab-in-only concern. If tabbableId also preferred it, selecting a
724
+ // single marker by clicking it on the map (which never touches lastActiveIdRef) would relocate
725
+ // tabIndex to match it on every click.
726
+ it('is not influenced by a selection change before any position is established', () => {
727
+ const eb = makeEventBus()
728
+ const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
729
+ act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'b', layerId: 'roads' }] }))
730
+ expect(result.current.tabbableId).toBe('a')
731
+ })
732
+
733
+ it('does not chase a single item toggled selected/deselected purely via mouse clicks, with no keyboard interaction at all', () => {
734
+ const eb = makeEventBus()
735
+ const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
736
+ act(() => eb.trigger(SELECTION_CHANGE, { selectedMarkers: ['a'] }))
737
+ expect(result.current.tabbableId).toBe('a')
738
+ act(() => eb.trigger(SELECTION_CHANGE, { selectedMarkers: ['b'] })) // clicked a different marker
739
+ expect(result.current.tabbableId).toBe('a') // still the structural first item — never moved
740
+ act(() => eb.trigger(SELECTION_CHANGE, { selectedMarkers: ['c'] }))
741
+ expect(result.current.tabbableId).toBe('a')
742
+ })
743
+
744
+ it('stays on the established position when a selection change arrives from elsewhere (e.g. clicking a marker on the map)', () => {
745
+ const eb = makeEventBus()
746
+ const refs = makeRefs()
747
+ const el = refs.featuresRef.current
748
+ document.body.appendChild(el)
749
+ const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS, eventBus: eb }))
750
+ act(() => result.current.onFocus()) // establishes position at 'a'
751
+ fireKey(el, 'ArrowDown') // establishes position at 'b'
752
+ act(() => result.current.onBlur())
753
+ // A selection made outside the list (e.g. a mouse click on a map marker) never touches
754
+ // lastActiveIdRef — it only changes selectedIds via interact:selectionchange.
755
+ act(() => eb.trigger(SELECTION_CHANGE, { selectedMarkers: ['c'] }))
756
+ expect(result.current.tabbableId).toBe('b') // stays put — does not jump to the newly-selected 'c'
757
+ unmount(); el.remove()
758
+ })
759
+ })
760
+
761
+ // ─── useFeatureFocus — selectItem (click / touch / voice-control activation) ─
762
+
763
+ describe('useFeatureFocus — selectItem', () => {
764
+ afterEach(() => { document.body.innerHTML = '' })
765
+
766
+ it('sets activeFeatureId to the clicked item', () => {
767
+ const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS }))
768
+ act(() => result.current.selectItem('b'))
769
+ expect(result.current.activeFeatureId).toBe('b')
770
+ })
771
+
772
+ it('emits map:setactivefeature then map:selectfeature for the clicked item', () => {
773
+ const eb = makeEventBus()
774
+ const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
775
+ act(() => result.current.selectItem('b'))
776
+ expect(eb.emit).toHaveBeenNthCalledWith(1, SET_ACTIVE, { id: 'b' })
777
+ expect(eb.emit).toHaveBeenNthCalledWith(2, SELECT)
778
+ })
779
+
780
+ it('moves real DOM focus to the clicked option', () => {
781
+ const refs = makeRefs()
782
+ const option = document.createElement('li')
783
+ option.dataset.id = 'b'
784
+ option.focus = jest.fn()
785
+ refs.featuresRef.current.appendChild(option)
786
+ const { result } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
787
+ act(() => result.current.selectItem('b'))
788
+ expect(option.focus).toHaveBeenCalledWith({ preventScroll: true })
789
+ })
790
+
791
+ it('does not throw when eventBus is not provided', () => {
792
+ const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS }))
793
+ expect(() => act(() => result.current.selectItem('b'))).not.toThrow()
794
+ })
795
+
796
+ it('does not throw when featuresRef.current is null (e.g. unmounted)', () => {
797
+ const refs = { ...makeRefs(), featuresRef: { current: null } }
798
+ const { result } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
799
+ expect(() => act(() => result.current.selectItem('b'))).not.toThrow()
800
+ expect(result.current.activeFeatureId).toBe('b') // state still updates even though there's no element to focus
801
+ })
802
+ })
@@ -9,7 +9,7 @@ const SET_FEATURES_SUPPRESSED = 'map:setfeaturessuppressed'
9
9
  * actually visible — which is what drives <Features>'s own tabIndex/aria-hidden.
10
10
  *
11
11
  * @param {object} eventBus
12
- * @returns {{ items: Array<{ id: string, label: string }>, multiselectable: boolean }}
12
+ * @returns {{ items: Array<{ id: string, label: string, x?: number, y?: number }>, multiselectable: boolean }}
13
13
  */
14
14
  export function useFeatureItems (eventBus) {
15
15
  const [items, setItems] = useState([])
@@ -58,7 +58,8 @@ export const useInterfaceAPI = () => {
58
58
  const handleAppClosed = () => dispatchRef.current({ type: 'TOGGLE_APP_VISIBLE', payload: false })
59
59
  const handleAddPanel = ({ id, config }) => dispatchRef.current({ type: 'ADD_PANEL', payload: { id, config } })
60
60
  const handleRemovePanel = (id) => dispatchRef.current({ type: 'REMOVE_PANEL', payload: id })
61
- const handleShowPanel = ({ id, focus = true }) => dispatchRef.current({ type: 'OPEN_PANEL', payload: { panelId: id, focusOnOpen: focus } })
61
+ const handleShowPanel = ({ id, focus = true, triggeringElement }) =>
62
+ dispatchRef.current({ type: 'OPEN_PANEL', payload: { panelId: id, focusOnOpen: focus, props: triggeringElement ? { triggeringElement } : undefined } })
62
63
  const handleHidePanel = (id) => dispatchRef.current({ type: 'CLOSE_PANEL', payload: id })
63
64
  const handleAddControl = ({ id, config }) => dispatchRef.current({ type: 'ADD_CONTROL', payload: { id, config } })
64
65
 
@@ -92,6 +92,15 @@ describe('useInterfaceAPI', () => {
92
92
  expect(mockDispatch).toHaveBeenCalledWith({ type: 'OPEN_PANEL', payload: { panelId: 'panel1', focusOnOpen: false } })
93
93
  })
94
94
 
95
+ it('dispatches OPEN_PANEL with props.triggeringElement when one is given, so Panel can return focus to it on close', () => {
96
+ const triggeringElement = document.createElement('button')
97
+ renderHook(() => useInterfaceAPI())
98
+ act(() => mockEventBus.emit('app:showpanel', { id: 'panel1', triggeringElement }))
99
+ expect(mockDispatch).toHaveBeenCalledWith({
100
+ type: 'OPEN_PANEL', payload: { panelId: 'panel1', focusOnOpen: true, props: { triggeringElement } }
101
+ })
102
+ })
103
+
95
104
  it('dispatches CLOSE_PANEL on app:hidepanel', () => {
96
105
  renderHook(() => useInterfaceAPI())
97
106
  act(() => mockEventBus.emit('app:hidepanel', 'panel1'))
@@ -5,6 +5,71 @@ import { useConfig } from '../store/configContext.js'
5
5
  import { useApp } from '../store/appContext.js'
6
6
  import { useService } from '../store/serviceContext.js'
7
7
 
8
+ const normalizeKey = (e) => {
9
+ let key
10
+
11
+ // Use e.code for letters to avoid 'dead' keys with Alt/AltGr
12
+ if (/^Key[A-Z]$/.test(e.code)) {
13
+ key = e.code.slice(3) // NOSONAR: strip "Key" prefix, e.g. "KeyI" -> "I"
14
+ } else {
15
+ key = e.key // works for arrows, numpad, punctuation
16
+ }
17
+
18
+ // Normalize numpad add/subtract to symbols
19
+ if (key === 'Add' || key === 'NumpadAdd') {
20
+ key = '+'
21
+ } else if (key === 'Subtract' || key === 'NumpadSubtract') {
22
+ key = '-'
23
+ } else {
24
+ // No action
25
+ }
26
+
27
+ // Check altKey first: AltGr (used on many non-US keyboards) reports both altKey
28
+ // and ctrlKey as true, and should still resolve to the Alt+ binding, not Ctrl+.
29
+ if (e.altKey) {
30
+ return `Alt+${key}`
31
+ }
32
+ if (e.ctrlKey) {
33
+ return `Ctrl+${key}`
34
+ }
35
+ return key
36
+ }
37
+
38
+ const resolveAction = (actions, type, key) => {
39
+ const actionName = keyboardMappings[type][key]
40
+ return actionName && actions[actionName] ? actionName : null
41
+ }
42
+
43
+ // keydown (pan/zoom) stays on the viewport so arrows only fire when the map has focus.
44
+ // keyup (Alt+K and other global shortcuts) attaches to the app container so it fires
45
+ // from anywhere within the app, including the features listbox.
46
+ const createKeyEventHandlers = (actions) => {
47
+ const handleKeyDown = (e) => {
48
+ const key = normalizeKey(e)
49
+ const actionName = resolveAction(actions, 'keydown', key)
50
+ if (actionName) {
51
+ actions[actionName](e)
52
+ e.preventDefault()
53
+ return
54
+ }
55
+ // A keyup-bound shortcut (e.g. Alt+Arrow label navigation) still acts on release,
56
+ // but its browser default (e.g. arrow-key scroll) must be suppressed now or it's too late.
57
+ if (resolveAction(actions, 'keyup', key)) {
58
+ e.preventDefault()
59
+ }
60
+ }
61
+
62
+ const handleKeyUp = (e) => {
63
+ const actionName = resolveAction(actions, 'keyup', normalizeKey(e))
64
+ if (actionName) {
65
+ actions[actionName](e)
66
+ e.preventDefault()
67
+ }
68
+ }
69
+
70
+ return { handleKeyDown, handleKeyUp }
71
+ }
72
+
8
73
  export function useKeyboardShortcuts (containerRef) {
9
74
  const { mapProvider, panDelta, nudgePanDelta, zoomDelta, nudgeZoomDelta, readMapText } = useConfig()
10
75
  const { dispatch, layoutRefs } = useApp()
@@ -27,42 +92,8 @@ export function useKeyboardShortcuts (containerRef) {
27
92
  readMapText
28
93
  })
29
94
 
30
- const normalizeKey = (e) => {
31
- let key
32
-
33
- // Use e.code for letters to avoid 'dead' keys with Alt/AltGr
34
- if (/^Key[A-Z]$/.test(e.code)) {
35
- key = e.code.slice(3) // NOSONAR: strip "Key" prefix, e.g. "KeyI" -> "I"
36
- } else {
37
- key = e.key // works for arrows, numpad, punctuation
38
- }
39
-
40
- // Normalize numpad add/subtract to symbols
41
- if (key === 'Add' || key === 'NumpadAdd') {
42
- key = '+'
43
- } else if (key === 'Subtract' || key === 'NumpadSubtract') {
44
- key = '-'
45
- } else {
46
- // No action
47
- }
48
-
49
- return e.altKey ? `Alt+${key}` : key
50
- }
51
-
52
- const handle = (type) => (e) => {
53
- const actionName = keyboardMappings[type][normalizeKey(e)]
54
- if (actionName && actions[actionName]) {
55
- actions[actionName](e)
56
- e.preventDefault()
57
- }
58
- }
59
-
60
- const handleKeyDown = handle('keydown')
61
- const handleKeyUp = handle('keyup')
95
+ const { handleKeyDown, handleKeyUp } = createKeyEventHandlers(actions)
62
96
 
63
- // keydown (pan/zoom) stays on the viewport so arrows only fire when the map has focus.
64
- // keyup (Alt+K and other global shortcuts) attaches to the app container so it fires
65
- // from anywhere within the app, including the features listbox.
66
97
  el.addEventListener('keydown', handleKeyDown)
67
98
  appEl.addEventListener('keyup', handleKeyUp)
68
99
 
@@ -138,6 +138,58 @@ describe('useKeyboardShortcuts', () => {
138
138
  expect(actions.stopPan).toHaveBeenCalled()
139
139
  })
140
140
 
141
+ test('does nothing on keyup when the key has no mapped action', () => {
142
+ const { appContainerRef, actions } = setup({ keyup: { ArrowUp: 'stopPan' }, actions: { stopPan: jest.fn() } })
143
+ renderHook(() => useKeyboardShortcuts({ current: document.createElement('div') }))
144
+
145
+ const evt = new KeyboardEvent('keyup', { key: 'Z' })
146
+ const spy = jest.spyOn(evt, 'preventDefault')
147
+ appContainerRef.current.dispatchEvent(evt)
148
+
149
+ expect(spy).not.toHaveBeenCalled()
150
+ expect(actions.stopPan).not.toHaveBeenCalled()
151
+ })
152
+
153
+ test('suppresses the browser default on keydown for a keyup-bound shortcut, without firing the action early', () => {
154
+ const { containerRef, appContainerRef, actions } = setup({
155
+ keyup: { 'Alt+ArrowUp': 'highlightNextLabel' },
156
+ actions: { highlightNextLabel: jest.fn() }
157
+ })
158
+ renderHook(() => useKeyboardShortcuts(containerRef))
159
+
160
+ const keydownEvt = new KeyboardEvent('keydown', { key: 'ArrowUp', altKey: true })
161
+ const keydownSpy = jest.spyOn(keydownEvt, 'preventDefault')
162
+ containerRef.current.dispatchEvent(keydownEvt)
163
+ expect(keydownSpy).toHaveBeenCalled()
164
+ expect(actions.highlightNextLabel).not.toHaveBeenCalled()
165
+
166
+ appContainerRef.current.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowUp', altKey: true }))
167
+ expect(actions.highlightNextLabel).toHaveBeenCalled()
168
+ })
169
+
170
+ test('normalizes Ctrl+key combinations', () => {
171
+ const { containerRef, actions } = setup({
172
+ keydown: { 'Ctrl+I': 'getInfo' },
173
+ actions: { getInfo: jest.fn() }
174
+ })
175
+ renderHook(() => useKeyboardShortcuts(containerRef))
176
+
177
+ containerRef.current.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyI', key: 'i', ctrlKey: true }))
178
+ expect(actions.getInfo).toHaveBeenCalled()
179
+ })
180
+
181
+ test('treats AltGr (altKey + ctrlKey both true) as Alt+, not Ctrl+', () => {
182
+ const { containerRef, actions } = setup({
183
+ keydown: { 'Alt+I': 'getInfo', 'Ctrl+I': 'zoomIn' },
184
+ actions: { getInfo: jest.fn(), zoomIn: jest.fn() }
185
+ })
186
+ renderHook(() => useKeyboardShortcuts(containerRef))
187
+
188
+ containerRef.current.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyI', key: 'i', altKey: true, ctrlKey: true }))
189
+ expect(actions.getInfo).toHaveBeenCalled()
190
+ expect(actions.zoomIn).not.toHaveBeenCalled()
191
+ })
192
+
141
193
  test('prevents default when action exists, does nothing otherwise', () => {
142
194
  const { containerRef } = setup({ keydown: { I: 'zoomIn', X: 'nonExistent' } })
143
195
  renderHook(() => useKeyboardShortcuts(containerRef))
@@ -1,4 +1,4 @@
1
- import { useEffect } from 'react'
1
+ import { useEffect, useLayoutEffect } from 'react'
2
2
  import { useResizeObserver } from './useResizeObserver.js'
3
3
  import { constrainKeyboardFocus } from '../../utils/constrainKeyboardFocus.js'
4
4
  import { toggleInertElements } from '../../utils/toggleInertElements.js'
@@ -62,6 +62,44 @@ const setSlotCSSVar = (slot, layoutRefs, primaryMargin) => {
62
62
  root.style.setProperty(MODAL_MAX_HEIGHT, `${mainRect.height - relTop - primaryMargin}px`)
63
63
  }
64
64
 
65
+ // Computes and applies --modal-inset/--modal-max-height for the panel's current slot. Shared by
66
+ // the resize observer (keeps position correct across later layout changes while open) and the
67
+ // open-triggered layout effect below (panels stay permanently mounted — see mapPanels.js — so
68
+ // opening one doesn't itself resize mainRef and wouldn't otherwise trigger a recalc).
69
+ const recalcModalPosition = ({ isModal, mainRef, panelRef, buttonContainerEl, layoutRefs }) => {
70
+ if (!isModal || !mainRef.current) {
71
+ return
72
+ }
73
+
74
+ const root = document.documentElement
75
+ const styles = getComputedStyle(root)
76
+ const dividerGap = Number.parseInt(styles.getPropertyValue('--divider-gap'), 10)
77
+ const primaryMargin = Number.parseInt(styles.getPropertyValue('--primary-gap'), 10)
78
+ const slot = panelRef.current.dataset.slot
79
+
80
+ // Button-adjacent panels: position next to the controlling button.
81
+ // Use slot name (not buttonContainerEl) as the gate — buttonContainerEl may be undefined
82
+ // when there is no triggeringElement (e.g. panel opened programmatically).
83
+ // Dynamically query via aria-controls to handle stale triggeringElement after breakpoint changes.
84
+ if (slot?.endsWith('-button')) {
85
+ const panelElId = panelRef.current?.id
86
+ const currentButtonEl = panelElId ? document.querySelector(`[aria-controls="${panelElId}"]`) : null
87
+ const effectiveContainer = currentButtonEl?.parentElement ??
88
+ (buttonContainerEl?.isConnected ? buttonContainerEl : null) ??
89
+ document.querySelector(`[data-button-slot="${slot}"]`)
90
+
91
+ if (!effectiveContainer) {
92
+ return
93
+ }
94
+
95
+ setButtonCSSVar(effectiveContainer, mainRef, dividerGap)
96
+ return
97
+ }
98
+
99
+ // Slot-based panels: derive position from the slot container element
100
+ setSlotCSSVar(slot, layoutRefs, primaryMargin)
101
+ }
102
+
65
103
  const useModalKeyHandler = (panelRef, isModal, handleClose) => {
66
104
  useEffect(() => {
67
105
  if (!isModal) {
@@ -131,40 +169,16 @@ export function useModalPanelBehaviour ({
131
169
 
132
170
  useModalKeyHandler(panelRef, isModal, handleClose)
133
171
 
134
- // === Set --modal-inset and --modal-max-height, recalculate on mainRef resize === //
135
- useResizeObserver([mainRef], () => {
136
- if (!isModal || !mainRef.current) {
137
- return
138
- }
139
-
140
- const root = document.documentElement
141
- const styles = getComputedStyle(root)
142
- const dividerGap = Number.parseInt(styles.getPropertyValue('--divider-gap'), 10)
143
- const primaryMargin = Number.parseInt(styles.getPropertyValue('--primary-gap'), 10)
144
- const slot = panelRef.current.dataset.slot
145
-
146
- // Button-adjacent panels: position next to the controlling button.
147
- // Use slot name (not buttonContainerEl) as the gate — buttonContainerEl may be undefined
148
- // when there is no triggeringElement (e.g. panel opened programmatically).
149
- // Dynamically query via aria-controls to handle stale triggeringElement after breakpoint changes.
150
- if (slot?.endsWith('-button')) {
151
- const panelElId = panelRef.current?.id
152
- const currentButtonEl = panelElId ? document.querySelector(`[aria-controls="${panelElId}"]`) : null
153
- const effectiveContainer = currentButtonEl?.parentElement ??
154
- (buttonContainerEl?.isConnected ? buttonContainerEl : null) ??
155
- document.querySelector(`[data-button-slot="${slot}"]`)
156
-
157
- if (!effectiveContainer) {
158
- return
159
- }
160
-
161
- setButtonCSSVar(effectiveContainer, mainRef, dividerGap)
162
- return
163
- }
172
+ // === Recalculate on mainRef resize (keeps position correct while already open) === //
173
+ useResizeObserver([mainRef], () => recalcModalPosition({ isModal, mainRef, panelRef, buttonContainerEl, layoutRefs }))
164
174
 
165
- // Slot-based panels: derive position from the slot container element
166
- setSlotCSSVar(slot, layoutRefs, primaryMargin)
167
- })
175
+ // === Recalculate the moment the panel opens === //
176
+ // The resize observer above won't fire on its own here: its size cache persists across
177
+ // opens/closes (the panel no longer mounts fresh each time), and opening a panel doesn't
178
+ // itself resize mainRef. This is what actually positions the panel at open time.
179
+ useLayoutEffect(() => {
180
+ recalcModalPosition({ isModal, mainRef, panelRef, buttonContainerEl, layoutRefs })
181
+ }, [isModal, mainRef, panelRef, buttonContainerEl, layoutRefs])
168
182
 
169
183
  // === Click on modal backdrop to close === //
170
184
  useEffect(() => {
@@ -218,6 +218,65 @@ describe('useModalPanelBehaviour', () => {
218
218
  })
219
219
  })
220
220
 
221
+ // Regression: panels are mounted permanently and only toggle `hidden` (see mapPanels.js), so
222
+ // opening one doesn't itself resize mainRef. This uses the *real* useResizeObserver (backed by
223
+ // a fake ResizeObserver, not the blanket `(_, cb) => cb()` mock used above) so its size-change
224
+ // dedupe is actually exercised — that dedupe is what silently swallowed the reposition on open.
225
+ describe('repositioning on open when mainRef never resizes', () => {
226
+ const buttonSlot = 'map-styles-button'
227
+
228
+ beforeEach(() => {
229
+ const { useResizeObserver: actualUseResizeObserver } = jest.requireActual('./useResizeObserver.js')
230
+ useResizeObserverModule.useResizeObserver.mockImplementation(actualUseResizeObserver)
231
+
232
+ // Real ResizeObserver always delivers once per observe() call — mirror that, and let the
233
+ // real hook's own prevSizes dedupe decide whether the wrapped callback actually runs.
234
+ global.ResizeObserver = jest.fn(function (cb) {
235
+ this.observe = (target) => {
236
+ const entries = [{ target, contentRect: { width: 100, height: 50 } }]
237
+ cb(entries)
238
+ }
239
+ this.disconnect = jest.fn()
240
+ })
241
+ jest.spyOn(global, 'requestAnimationFrame').mockImplementation(cb => { cb(); return 0 })
242
+ jest.spyOn(global, 'cancelAnimationFrame').mockImplementation(() => {})
243
+ jest.spyOn(globalThis, 'getComputedStyle').mockReturnValue({ getPropertyValue: () => '8' })
244
+
245
+ Object.defineProperty(refs.main.current, 'getBoundingClientRect', {
246
+ value: () => ({ top: 0, right: 100, bottom: 50, left: 0, width: 100, height: 50 }),
247
+ configurable: true
248
+ })
249
+ Object.defineProperty(elements.buttonContainer, 'getBoundingClientRect', {
250
+ value: () => ({ top: 10, right: 80, bottom: 40, left: 20, width: 60, height: 30 }),
251
+ configurable: true
252
+ })
253
+
254
+ refs.panel.current.dataset.slot = buttonSlot
255
+ const button = document.createElement('button')
256
+ button.setAttribute(ARIA_CONTROLS, PANEL_ID)
257
+ elements.buttonContainer.appendChild(button)
258
+ document.body.appendChild(elements.buttonContainer)
259
+ })
260
+
261
+ afterEach(() => {
262
+ jest.restoreAllMocks()
263
+ })
264
+
265
+ it('positions the panel the moment it opens, not just when mainRef resizes', () => {
266
+ // Mounted closed, as it would be permanently — mainRef's size gets recorded but the
267
+ // calc no-ops on the isModal guard.
268
+ const { rerender } = render(<TestComponent isModal={false} />)
269
+ expect(document.documentElement.style.getPropertyValue(MODAL_INSET)).toBe('')
270
+
271
+ // Open it. mainRef hasn't resized, so the resize observer's own delivery is deduped
272
+ // away — only the open-triggered layout effect can position it here.
273
+ rerender(<TestComponent isModal />)
274
+
275
+ expect(document.documentElement.style.getPropertyValue(MODAL_INSET)).toContain('10px')
276
+ expect(document.documentElement.style.getPropertyValue(MODAL_MAX_HEIGHT)).toContain('px')
277
+ })
278
+ })
279
+
221
280
  describe('focus management', () => {
222
281
  it('redirects focus into panel when focus enters app but outside panel', () => {
223
282
  refs.panel.current.focus = jest.fn()
@@ -11,14 +11,16 @@ import { layoutSlots } from '../renderer/slots'
11
11
  import { SlotRenderer } from '../renderer/SlotRenderer'
12
12
  import { HtmlElementHost } from '../renderer/HtmlElementHost'
13
13
  import { Hints } from '../components/Hints/Hints.jsx'
14
+ import { hasOpenModalPanel } from '../renderer/slotHelpers.js'
14
15
  import { getMapThemeVars } from '../../config/mapTheme.js'
15
16
 
16
17
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
17
18
  // sonarjs/disable-next-line function-name
18
19
  export const Layout = () => {
19
20
  const { id, mapLabel, mapHintText } = useConfig()
20
- const { breakpoint, interfaceType, preferredColorScheme, layoutRefs, isLayoutReady, hasExclusiveControl, isFullscreen } = useApp()
21
+ const { breakpoint, interfaceType, preferredColorScheme, layoutRefs, isLayoutReady, hasExclusiveControl, isFullscreen, openPanels, panelConfig } = useApp()
21
22
  const { mapStyle } = useMap()
23
+ const showModalBackdrop = hasOpenModalPanel(openPanels ?? {}, panelConfig ?? {}, breakpoint)
22
24
 
23
25
  useLayoutMeasurements()
24
26
  useFocusVisible()
@@ -108,7 +110,7 @@ export const Layout = () => {
108
110
  </div>
109
111
  <div className='im-o-app__modal' ref={layoutRefs.modalRef}>
110
112
  <SlotRenderer slot={layoutSlots.MODAL} />
111
- <div className='im-o-app__modal-backdrop' />
113
+ <div className={`im-o-app__modal-backdrop${showModalBackdrop ? ' im-o-app__modal-backdrop--visible' : ''}`} />
112
114
  </div>
113
115
  </div>
114
116
  </div>
@@ -95,6 +95,27 @@ describe('Layout', () => {
95
95
  expect(screen.getByTestId('slot-top-left')).toBeInTheDocument()
96
96
  expect(screen.getByTestId('slot-bottom-right')).toBeInTheDocument()
97
97
  expect(screen.getByTestId('slot-modal')).toBeInTheDocument()
98
+
99
+ const backdrop = root.querySelector('.im-o-app__modal-backdrop')
100
+ expect(backdrop).toBeInTheDocument()
101
+ expect(backdrop).not.toHaveClass('im-o-app__modal-backdrop--visible')
102
+ })
103
+
104
+ test('shows the modal backdrop only while a modal-configured panel is open', () => {
105
+ useApp.mockReturnValue({
106
+ breakpoint: 'mobile',
107
+ interfaceType: 'map',
108
+ preferredColorScheme: 'dark',
109
+ layoutRefs: mockRefs,
110
+ isLayoutReady: true,
111
+ hasExclusiveControl: false,
112
+ isFullscreen: true,
113
+ openPanels: { settings: { props: {} } },
114
+ panelConfig: { settings: { mobile: { modal: true } } }
115
+ })
116
+ render(<Layout />)
117
+ const root = document.getElementById('myApp-im-app')
118
+ expect(root.querySelector('.im-o-app__modal-backdrop')).toHaveClass('im-o-app__modal-backdrop--visible')
98
119
  })
99
120
 
100
121
  test('applies "not-ready" class when layout is not ready', () => {
@@ -363,7 +363,13 @@
363
363
  z-index: 1000;
364
364
  }
365
365
 
366
- .im-c-panel + .im-o-app__modal-backdrop {
366
+ // Visibility is JS-driven (Layout.jsx) since multiple panels can share this slot.
367
+ .im-o-app__modal-backdrop {
368
+ display: none;
369
+ }
370
+
371
+ .im-o-app__modal-backdrop--visible {
372
+ display: block;
367
373
  position: absolute;
368
374
  top: 0;
369
375
  left: 0;
@@ -19,8 +19,8 @@ const _setProviderSupportedShortcuts = (state, ids = []) => {
19
19
 
20
20
  const _getKeyboardShortcuts = (state, appConfig = {}) => {
21
21
  const filteredCore = coreShortcuts.filter(s => {
22
- // Must be supported by provider
23
- if (!state.providerSupportedIds.has(s.id)) {
22
+ // Must be supported by the map provider, unless the shortcut is map-provider-agnostic (no capability dependency, so no adapter needs to declare it).
23
+ if (!s.mapProviderAgnostic && !state.providerSupportedIds.has(s.id)) {
24
24
  return false
25
25
  }
26
26
  // Check requiredConfig - all specified config values must be truthy