@defra/interactive-map 0.0.22-alpha → 0.0.23-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 (190) 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/button-definition.md +39 -0
  7. package/docs/api/context.md +4 -4
  8. package/docs/api/map-style-config.md +11 -2
  9. package/docs/api/marker-config.md +38 -7
  10. package/docs/api/panel-definition.md +7 -3
  11. package/docs/api/symbol-config.md +10 -26
  12. package/docs/api/symbol-registry.md +28 -13
  13. package/docs/api.md +24 -2
  14. package/docs/assets/css/{docusaurus.css → docusaurus.scss} +18 -12
  15. package/docs/assets/images/toggle-marker-label.jpg +0 -0
  16. package/docs/examples/index.mdx +12 -0
  17. package/docs/examples/toggle-marker-label.mdx +89 -0
  18. package/docs/plugins/interact.md +4 -11
  19. package/docusaurus.config.cjs +2 -1
  20. package/package.json +8 -8
  21. package/plugins/beta/datasets/dist/adapters/maplibre/esm/im-datasets-ml-adapter.js +1 -1
  22. package/plugins/beta/datasets/dist/css/index.css +2 -2
  23. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  24. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-maplibre-adapter.js +1 -1
  25. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-plugin.js +1 -1
  26. package/plugins/beta/datasets/dist/umd/maplibre/index.js +1 -1
  27. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +5 -5
  28. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +24 -29
  29. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +2 -2
  30. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +3 -3
  31. package/plugins/beta/datasets/src/components/KeySvg.jsx +2 -2
  32. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +1 -2
  33. package/plugins/beta/datasets/src/components/svgProperties.js +2 -2
  34. package/plugins/beta/datasets/src/panels/Key.module.scss +2 -2
  35. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  36. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  37. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  38. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  39. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  40. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  41. package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
  42. package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
  43. package/plugins/beta/use-location/src/manifest.js +1 -0
  44. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  45. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  46. package/plugins/interact/dist/umd/index.js +1 -1
  47. package/plugins/interact/src/InteractInit.jsx +23 -55
  48. package/plugins/interact/src/InteractInit.test.js +58 -80
  49. package/plugins/interact/src/defaults.js +1 -3
  50. package/plugins/interact/src/hooks/useAttachEvents.js +46 -0
  51. package/plugins/interact/src/hooks/useAttachEvents.test.js +65 -0
  52. package/plugins/interact/src/hooks/useCrossHairVisibility.js +47 -0
  53. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +54 -0
  54. package/plugins/interact/src/hooks/useHighlightSync.js +30 -8
  55. package/plugins/interact/src/hooks/useHighlightSync.test.js +127 -80
  56. package/plugins/interact/src/hooks/useInteractionHandlers.js +18 -17
  57. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +22 -61
  58. package/plugins/interact/src/hooks/useMapItemList.js +187 -0
  59. package/plugins/interact/src/hooks/useMapItemList.test.js +584 -0
  60. package/plugins/interact/src/index.js +0 -2
  61. package/plugins/interact/src/index.test.js +0 -2
  62. package/plugins/interact/src/manifest.js +37 -6
  63. package/plugins/interact/src/reducer.js +8 -4
  64. package/plugins/interact/src/reducer.test.js +18 -3
  65. package/plugins/interact/src/utils/buildStylesMap.js +21 -10
  66. package/plugins/interact/src/utils/buildStylesMap.test.js +29 -29
  67. package/plugins/interact/src/utils/spatial.js +0 -15
  68. package/plugins/interact/src/utils/spatial.test.js +1 -13
  69. package/plugins/search/dist/css/index.css +1 -1
  70. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  71. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  72. package/plugins/search/src/components/CloseButton/CloseButton.module.scss +2 -0
  73. package/plugins/search/src/components/OpenButton/OpenButton.jsx +1 -1
  74. package/plugins/search/src/components/OpenButton/OpenButton.module.scss +3 -0
  75. package/plugins/search/src/search.scss +1 -0
  76. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  77. package/providers/beta/esri/src/esriProvider.js +4 -0
  78. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  79. package/providers/maplibre/dist/esm/index.js +1 -1
  80. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  81. package/providers/maplibre/dist/umd/index.js +1 -1
  82. package/providers/maplibre/src/index.js +1 -1
  83. package/providers/maplibre/src/index.test.js +9 -14
  84. package/providers/maplibre/src/mapEvents.js +11 -5
  85. package/providers/maplibre/src/mapEvents.test.js +14 -3
  86. package/providers/maplibre/src/maplibreProvider.js +18 -11
  87. package/providers/maplibre/src/maplibreProvider.test.js +43 -36
  88. package/providers/maplibre/src/utils/highlightFeatures.js +102 -57
  89. package/providers/maplibre/src/utils/highlightFeatures.test.js +162 -51
  90. package/providers/maplibre/src/utils/maplibreFixes.js +10 -0
  91. package/providers/maplibre/src/utils/maplibreFixes.test.js +31 -0
  92. package/providers/maplibre/src/utils/patternImages.js +10 -12
  93. package/providers/maplibre/src/utils/patternImages.test.js +145 -167
  94. package/providers/maplibre/src/utils/rasteriseToImageData.js +1 -4
  95. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +23 -24
  96. package/providers/maplibre/src/utils/symbolImages.js +23 -86
  97. package/providers/maplibre/src/utils/symbolImages.test.js +60 -105
  98. package/src/App/components/Actions/Actions.module.scss +1 -0
  99. package/src/App/components/Actions/Actions.test.jsx +17 -4
  100. package/src/App/components/Hints/Hints.jsx +52 -0
  101. package/src/App/components/Hints/Hints.module.scss +37 -0
  102. package/src/App/components/Hints/Hints.test.jsx +104 -0
  103. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +57 -19
  104. package/src/App/components/KeyboardHelp/KeyboardHelp.module.scss +10 -0
  105. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +140 -31
  106. package/src/App/components/Markers/LabelMarker.jsx +14 -0
  107. package/src/App/components/Markers/LabelMarker.test.jsx +36 -0
  108. package/src/App/components/Markers/Markers.jsx +44 -75
  109. package/src/App/components/Markers/Markers.module.scss +58 -0
  110. package/src/App/components/Markers/Markers.test.jsx +90 -113
  111. package/src/App/components/Markers/SymbolLabelMarker.jsx +43 -0
  112. package/src/App/components/Markers/SymbolLabelMarker.test.jsx +56 -0
  113. package/src/App/components/Markers/SymbolMarker.jsx +31 -0
  114. package/src/App/components/Markers/SymbolMarker.test.jsx +72 -0
  115. package/src/App/components/Panel/Panel.jsx +1 -1
  116. package/src/App/components/Panel/Panel.module.scss +1 -0
  117. package/src/App/components/PopupMenu/PopupMenu.test.jsx +117 -1
  118. package/src/App/components/PopupMenu/usePopupMenu.js +132 -85
  119. package/src/App/components/Tabs/Tabs.jsx +68 -0
  120. package/src/App/components/Tabs/Tabs.module.scss +57 -0
  121. package/src/App/components/Tabs/Tabs.test.jsx +121 -0
  122. package/src/App/components/Tooltip/Tooltip.module.scss +1 -1
  123. package/src/App/components/Viewport/Features.jsx +35 -0
  124. package/src/App/components/Viewport/Features.module.scss +31 -0
  125. package/src/App/components/Viewport/Features.test.jsx +124 -0
  126. package/src/App/components/Viewport/MapStatus.jsx +0 -4
  127. package/src/App/components/Viewport/Viewport.jsx +35 -29
  128. package/src/App/components/Viewport/Viewport.module.scss +7 -21
  129. package/src/App/components/Viewport/Viewport.test.jsx +108 -65
  130. package/src/App/controls/keyboardActions.js +9 -3
  131. package/src/App/controls/keyboardActions.test.js +122 -87
  132. package/src/App/controls/keyboardShortcuts.js +1 -14
  133. package/src/App/hooks/useFeatureFocus.js +199 -0
  134. package/src/App/hooks/useFeatureFocus.test.js +635 -0
  135. package/src/App/hooks/useFeatureItems.js +30 -0
  136. package/src/App/hooks/useFeatureItems.test.js +97 -0
  137. package/src/App/hooks/useFocusVisible.js +4 -7
  138. package/src/App/hooks/useFocusVisible.test.js +6 -6
  139. package/src/App/hooks/useKeyboardHint.js +9 -34
  140. package/src/App/hooks/useKeyboardHint.test.js +34 -84
  141. package/src/App/hooks/useKeyboardShortcuts.js +8 -5
  142. package/src/App/hooks/useKeyboardShortcuts.test.js +16 -12
  143. package/src/App/hooks/useLayoutMeasurements.js +10 -4
  144. package/src/App/hooks/useLayoutMeasurements.test.js +30 -0
  145. package/src/App/hooks/useMarkerCursor.js +33 -0
  146. package/src/App/hooks/useMarkerCursor.test.js +87 -0
  147. package/src/App/hooks/useMarkersAPI.js +32 -9
  148. package/src/App/hooks/useMarkersAPI.test.js +162 -94
  149. package/src/App/layout/Layout.jsx +2 -0
  150. package/src/App/layout/Layout.test.jsx +3 -0
  151. package/src/App/registry/keyboardShortcutRegistry.js +3 -2
  152. package/src/App/registry/keyboardShortcutRegistry.test.js +6 -8
  153. package/src/App/renderer/mapButtons.js +5 -1
  154. package/src/App/renderer/mapButtons.test.js +37 -1
  155. package/src/App/store/ServiceProvider.jsx +4 -1
  156. package/src/App/store/ServiceProvider.test.jsx +11 -0
  157. package/src/App/store/appActionsMap.js +5 -1
  158. package/src/App/store/appActionsMap.test.js +13 -0
  159. package/src/App/store/appReducer.js +1 -0
  160. package/src/App/store/mapActionsMap.js +2 -1
  161. package/src/InteractiveMap/InteractiveMap.js +12 -0
  162. package/src/InteractiveMap/InteractiveMap.test.js +2 -0
  163. package/src/config/appConfig.js +3 -1
  164. package/src/config/appConfig.test.js +4 -0
  165. package/src/config/defaults.js +1 -1
  166. package/src/config/events.js +8 -0
  167. package/src/config/mapTheme.js +8 -4
  168. package/src/config/symbolConfig.js +23 -16
  169. package/src/scss/main.scss +3 -0
  170. package/src/scss/tools/_border-focus.scss +0 -1
  171. package/src/services/eventBus.js +15 -0
  172. package/src/services/eventBus.test.js +11 -0
  173. package/src/services/hints.js +47 -0
  174. package/src/services/hints.test.js +110 -0
  175. package/src/services/patternRegistry.js +69 -1
  176. package/src/services/patternRegistry.test.js +112 -1
  177. package/src/services/reverseGeocode.js +4 -0
  178. package/src/services/reverseGeocode.test.js +11 -1
  179. package/src/services/symbolRegistry.js +152 -17
  180. package/src/services/symbolRegistry.test.js +141 -31
  181. package/src/types.js +71 -14
  182. package/src/utils/constrainKeyboardFocus.js +5 -5
  183. package/src/utils/detectInterfaceType.js +27 -5
  184. package/src/utils/detectInterfaceType.test.js +29 -3
  185. package/src/utils/patternUtils.js +4 -67
  186. package/src/utils/patternUtils.test.js +1 -123
  187. package/src/utils/symbolUtils.js +11 -13
  188. package/src/utils/symbolUtils.test.js +18 -26
  189. package/plugins/interact/dist/css/index.css +0 -1
  190. package/plugins/interact/src/interact.scss +0 -24
@@ -0,0 +1,97 @@
1
+ import { renderHook, act } from '@testing-library/react'
2
+ import { useFeatureItems } from './useFeatureItems.js'
3
+
4
+ const SET_FEATURES = 'map:setfeatures' // NOSONAR
5
+
6
+ const makeEventBus = () => {
7
+ const listeners = {}
8
+ return {
9
+ on: jest.fn((e, fn) => { listeners[e] = fn }),
10
+ off: jest.fn(),
11
+ emit: (e, payload) => listeners[e]?.(payload)
12
+ }
13
+ }
14
+
15
+ // ─── useFeatureItems — initial state ─────────────────────────────────────────
16
+
17
+ describe('useFeatureItems — initial state', () => {
18
+ it('returns empty items and multiselectable false before any event', () => {
19
+ const { result } = renderHook(() => useFeatureItems(makeEventBus()))
20
+ expect(result.current.items).toEqual([])
21
+ expect(result.current.multiselectable).toBe(false)
22
+ })
23
+
24
+ it('returns empty items and multiselectable false when eventBus is undefined', () => {
25
+ const { result } = renderHook(() => useFeatureItems(undefined))
26
+ expect(result.current.items).toEqual([])
27
+ expect(result.current.multiselectable).toBe(false)
28
+ })
29
+ })
30
+
31
+ // ─── useFeatureItems — event subscription ────────────────────────────────────
32
+
33
+ describe('useFeatureItems — event subscription', () => {
34
+ it('subscribes to map:setfeatures on mount', () => {
35
+ const eb = makeEventBus()
36
+ renderHook(() => useFeatureItems(eb))
37
+ expect(eb.on).toHaveBeenCalledWith(SET_FEATURES, expect.any(Function))
38
+ })
39
+
40
+ it('unsubscribes on unmount', () => {
41
+ const eb = makeEventBus()
42
+ const { unmount } = renderHook(() => useFeatureItems(eb))
43
+ unmount()
44
+ expect(eb.off).toHaveBeenCalledWith(SET_FEATURES, expect.any(Function))
45
+ })
46
+
47
+ it('does not subscribe when eventBus is undefined', () => {
48
+ const spy = jest.spyOn(console, 'error').mockImplementation(() => {})
49
+ renderHook(() => useFeatureItems(undefined))
50
+ spy.mockRestore()
51
+ })
52
+ })
53
+
54
+ // ─── useFeatureItems — items updates ─────────────────────────────────────────
55
+
56
+ describe('useFeatureItems — items updates', () => {
57
+ it('updates items when map:setfeatures is emitted', () => {
58
+ const eb = makeEventBus()
59
+ const { result } = renderHook(() => useFeatureItems(eb))
60
+ const items = [{ id: 'a', label: 'Feature A' }, { id: 'b', label: 'Feature B' }]
61
+ act(() => eb.emit(SET_FEATURES, { items }))
62
+ expect(result.current.items).toEqual(items)
63
+ })
64
+
65
+ it('clears items when emitted with an empty array', () => {
66
+ const eb = makeEventBus()
67
+ const { result } = renderHook(() => useFeatureItems(eb))
68
+ act(() => eb.emit(SET_FEATURES, { items: [{ id: 'a', label: 'A' }] }))
69
+ act(() => eb.emit(SET_FEATURES, { items: [] }))
70
+ expect(result.current.items).toEqual([])
71
+ })
72
+
73
+ it('defaults items to empty array when items key is missing from payload', () => {
74
+ const eb = makeEventBus()
75
+ const { result } = renderHook(() => useFeatureItems(eb))
76
+ act(() => eb.emit(SET_FEATURES, {}))
77
+ expect(result.current.items).toEqual([])
78
+ })
79
+ })
80
+
81
+ // ─── useFeatureItems — multiselectable updates ───────────────────────────────
82
+
83
+ describe('useFeatureItems — multiselectable updates', () => {
84
+ it('sets multiselectable true when emitted with multiselectable: true', () => {
85
+ const eb = makeEventBus()
86
+ const { result } = renderHook(() => useFeatureItems(eb))
87
+ act(() => eb.emit(SET_FEATURES, { items: [], multiselectable: true }))
88
+ expect(result.current.multiselectable).toBe(true)
89
+ })
90
+
91
+ it('defaults multiselectable to false when not present in payload', () => {
92
+ const eb = makeEventBus()
93
+ const { result } = renderHook(() => useFeatureItems(eb))
94
+ act(() => eb.emit(SET_FEATURES, { items: [] }))
95
+ expect(result.current.multiselectable).toBe(false)
96
+ })
97
+ })
@@ -1,12 +1,9 @@
1
- import { useEffect, useRef } from 'react'
1
+ import { useEffect } from 'react'
2
2
  import { useApp } from '../store/appContext.js'
3
+ import { getInterfaceType } from '../../utils/detectInterfaceType.js'
3
4
 
4
5
  export function useFocusVisible () {
5
- const { interfaceType, layoutRefs } = useApp()
6
- const interfaceTypeRef = useRef(interfaceType)
7
-
8
- // Keep ref in sync - synchronous update ensures it's always current
9
- interfaceTypeRef.current = interfaceType
6
+ const { layoutRefs } = useApp()
10
7
 
11
8
  useEffect(() => {
12
9
  const scope = layoutRefs.appContainerRef.current
@@ -15,7 +12,7 @@ export function useFocusVisible () {
15
12
  }
16
13
 
17
14
  function handleFocusIn (e) {
18
- e.target.dataset.focusVisible = interfaceTypeRef.current === 'keyboard'
15
+ e.target.dataset.focusVisible = getInterfaceType() === 'keyboard'
19
16
  }
20
17
 
21
18
  function handleFocusOut (e) {
@@ -1,8 +1,12 @@
1
1
  import { renderHook } from '@testing-library/react'
2
2
  import { useFocusVisible } from './useFocusVisible'
3
3
  import { useApp } from '../store/appContext.js'
4
+ import { getInterfaceType } from '../../utils/detectInterfaceType.js'
4
5
 
5
6
  jest.mock('../store/appContext.js')
7
+ jest.mock('../../utils/detectInterfaceType.js', () => ({
8
+ getInterfaceType: jest.fn(() => 'keyboard')
9
+ }))
6
10
 
7
11
  describe('useFocusVisible', () => {
8
12
  let mockAppContainerRef, addEventListenerSpy, removeEventListenerSpy
@@ -13,8 +17,8 @@ describe('useFocusVisible', () => {
13
17
  addEventListenerSpy = jest.spyOn(document, 'addEventListener')
14
18
  removeEventListenerSpy = jest.spyOn(document, 'removeEventListener')
15
19
 
20
+ getInterfaceType.mockReturnValue('keyboard')
16
21
  useApp.mockReturnValue({
17
- interfaceType: 'keyboard',
18
22
  layoutRefs: { appContainerRef: mockAppContainerRef }
19
23
  })
20
24
  })
@@ -26,7 +30,6 @@ describe('useFocusVisible', () => {
26
30
 
27
31
  it('returns early when no scope', () => {
28
32
  useApp.mockReturnValue({
29
- interfaceType: 'keyboard',
30
33
  layoutRefs: { appContainerRef: { current: null } }
31
34
  })
32
35
 
@@ -58,10 +61,7 @@ describe('useFocusVisible', () => {
58
61
  })
59
62
 
60
63
  it('does not set focusVisible on focusin when not keyboard', () => {
61
- useApp.mockReturnValue({
62
- interfaceType: 'touch',
63
- layoutRefs: { appContainerRef: mockAppContainerRef }
64
- })
64
+ getInterfaceType.mockReturnValue('touch')
65
65
 
66
66
  renderHook(() => useFocusVisible())
67
67
 
@@ -1,47 +1,26 @@
1
1
  import { useEffect } from 'react'
2
+ import { getInterfaceType } from '../../utils/detectInterfaceType.js'
2
3
 
3
4
  export function useKeyboardHint ({
4
- interfaceType,
5
5
  containerRef,
6
- keyboardHintRef,
7
- keyboardHintVisible,
8
6
  onViewportFocusChange
9
7
  }) {
10
- const showHint = keyboardHintVisible
11
-
12
8
  useEffect(() => {
13
- if (!showHint || !containerRef.current) {
9
+ const el = containerRef.current
10
+ if (!el) {
14
11
  return undefined
15
12
  }
16
-
17
- const containerEl = containerRef.current
18
-
19
13
  const handleKeyDown = (e) => {
20
- if (['Escape', 'Tab'].includes(e.key)) {
21
- onViewportFocusChange(false)
22
- }
23
- }
24
-
25
- const handleMouseDown = (e) => {
26
- const clickedInsideViewport = containerEl.contains(e.target)
27
- const clickedInsideHint = keyboardHintRef.current?.contains(e.target)
28
-
29
- if (clickedInsideViewport && !clickedInsideHint) {
14
+ if (e.key === 'Escape') {
30
15
  onViewportFocusChange(false)
31
16
  }
32
17
  }
33
-
34
- containerEl.addEventListener('keydown', handleKeyDown)
35
- containerEl.addEventListener('mousedown', handleMouseDown)
36
-
37
- return () => {
38
- containerEl.removeEventListener('keydown', handleKeyDown)
39
- containerEl.removeEventListener('mousedown', handleMouseDown)
40
- }
41
- }, [showHint, containerRef, keyboardHintRef, onViewportFocusChange])
18
+ el.addEventListener('keydown', handleKeyDown)
19
+ return () => { el.removeEventListener('keydown', handleKeyDown) }
20
+ }, [containerRef, onViewportFocusChange])
42
21
 
43
22
  const handleFocus = () => {
44
- if (interfaceType === 'keyboard') {
23
+ if (getInterfaceType() === 'keyboard') {
45
24
  onViewportFocusChange(true)
46
25
  }
47
26
  }
@@ -50,9 +29,5 @@ export function useKeyboardHint ({
50
29
  onViewportFocusChange(false)
51
30
  }
52
31
 
53
- return {
54
- showHint,
55
- handleFocus,
56
- handleBlur
57
- }
32
+ return { handleFocus, handleBlur }
58
33
  }
@@ -1,136 +1,86 @@
1
1
  import { renderHook, act } from '@testing-library/react'
2
2
  import { useKeyboardHint } from './useKeyboardHint'
3
+ import { getInterfaceType } from '../../utils/detectInterfaceType.js'
3
4
 
4
- const createRefs = () => ({
5
- containerRef: { current: document.createElement('div') },
6
- keyboardHintRef: { current: document.createElement('div') }
7
- })
5
+ jest.mock('../../utils/detectInterfaceType.js', () => ({
6
+ getInterfaceType: jest.fn(() => 'keyboard')
7
+ }))
8
8
 
9
9
  const defaultProps = (overrides = {}) => ({
10
- interfaceType: 'keyboard',
11
- keyboardHintVisible: true,
10
+ containerRef: { current: document.createElement('div') },
12
11
  onViewportFocusChange: jest.fn(),
13
- ...createRefs(),
14
12
  ...overrides
15
13
  })
16
14
 
17
- describe('useKeyboardHint', () => {
18
- test('returns handlers and showHint', () => {
19
- const { result } = renderHook(() => useKeyboardHint(defaultProps()))
15
+ // ─── return shape ─────────────────────────────────────────────────────────────
20
16
 
21
- expect(result.current.showHint).toBe(true)
17
+ describe('useKeyboardHint — return shape', () => {
18
+ test('returns focus and blur handlers', () => {
19
+ const { result } = renderHook(() => useKeyboardHint(defaultProps()))
22
20
  expect(typeof result.current.handleFocus).toBe('function')
23
21
  expect(typeof result.current.handleBlur).toBe('function')
24
22
  })
23
+ })
25
24
 
26
- test('useEffect early-returns when showHint = false', () => {
27
- const props = defaultProps({ keyboardHintVisible: false })
28
- const spy = jest.spyOn(props.containerRef.current, 'addEventListener')
29
-
30
- renderHook(() => useKeyboardHint(props))
31
-
32
- expect(spy).not.toHaveBeenCalled()
33
- spy.mockRestore()
34
- })
35
-
36
- test('keydown Escape and Tab trigger onViewportFocusChange(false)', () => {
37
- const props = defaultProps()
38
-
39
- renderHook(() => useKeyboardHint(props))
40
-
41
- const keys = ['Escape', 'Tab']
42
- keys.forEach(key => {
43
- act(() => {
44
- props.containerRef.current.dispatchEvent(
45
- new KeyboardEvent('keydown', { key })
46
- )
47
- })
48
- })
49
-
50
- expect(props.onViewportFocusChange).toHaveBeenCalledTimes(2)
51
- expect(props.onViewportFocusChange).toHaveBeenCalledWith(false)
52
- })
53
-
54
- test('keydown with other keys does NOT trigger onViewportFocusChange', () => {
55
- const props = defaultProps()
56
-
57
- renderHook(() => useKeyboardHint(props))
58
-
59
- const keys = ['Enter', 'a']
60
- keys.forEach(key => {
61
- act(() => {
62
- props.containerRef.current.dispatchEvent(
63
- new KeyboardEvent('keydown', { key })
64
- )
65
- })
66
- })
67
-
68
- expect(props.onViewportFocusChange).not.toHaveBeenCalled()
69
- })
25
+ // ─── keydown listener ────────────────────────────────────────────────────────
70
26
 
71
- test('mousedown inside container but outside hint triggers false', () => {
27
+ describe('useKeyboardHint keydown listener', () => {
28
+ test('Escape calls onViewportFocusChange(false)', () => {
72
29
  const props = defaultProps()
73
- const child = document.createElement('div')
74
- props.containerRef.current.appendChild(child)
75
-
76
30
  renderHook(() => useKeyboardHint(props))
77
-
78
31
  act(() => {
79
- child.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
32
+ props.containerRef.current.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
80
33
  })
81
-
82
34
  expect(props.onViewportFocusChange).toHaveBeenCalledWith(false)
83
35
  })
84
36
 
85
- test('mousedown inside keyboardHintRef does NOT trigger change', () => {
37
+ test('non-Escape keydown does not call onViewportFocusChange', () => {
86
38
  const props = defaultProps()
87
- props.containerRef.current.appendChild(props.keyboardHintRef.current)
88
-
89
39
  renderHook(() => useKeyboardHint(props))
90
-
91
40
  act(() => {
92
- props.keyboardHintRef.current.dispatchEvent(
93
- new MouseEvent('mousedown', { bubbles: true })
94
- )
41
+ props.containerRef.current.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
95
42
  })
96
-
97
43
  expect(props.onViewportFocusChange).not.toHaveBeenCalled()
98
44
  })
99
45
 
100
- test('cleanup removes event listeners', () => {
46
+ test('does nothing when containerRef.current is null', () => {
47
+ const props = defaultProps({ containerRef: { current: null } })
48
+ expect(() => renderHook(() => useKeyboardHint(props))).not.toThrow()
49
+ })
50
+
51
+ test('cleanup removes keydown listener on unmount', () => {
101
52
  const props = defaultProps()
102
53
  const spy = jest.spyOn(props.containerRef.current, 'removeEventListener')
103
-
104
54
  const { unmount } = renderHook(() => useKeyboardHint(props))
105
-
106
55
  unmount()
107
- expect(spy).toHaveBeenCalled()
56
+ expect(spy).toHaveBeenCalledWith('keydown', expect.any(Function))
108
57
  })
58
+ })
59
+
60
+ // ─── handleFocus / handleBlur ─────────────────────────────────────────────────
109
61
 
110
- test('handleFocus triggers true only for keyboard interface', () => {
62
+ describe('useKeyboardHint handleFocus / handleBlur', () => {
63
+ beforeEach(() => getInterfaceType.mockReturnValue('keyboard'))
64
+
65
+ test('handleFocus calls onViewportFocusChange(true) for keyboard interface', () => {
111
66
  const props = defaultProps()
112
67
  const { result } = renderHook(() => useKeyboardHint(props))
113
-
114
68
  act(() => result.current.handleFocus())
115
-
116
69
  expect(props.onViewportFocusChange).toHaveBeenCalledWith(true)
117
70
  })
118
71
 
119
- test('handleFocus does not trigger if interfaceType is not keyboard', () => {
120
- const props = defaultProps({ interfaceType: 'mouse' })
72
+ test('handleFocus does not call onViewportFocusChange for non-keyboard interface', () => {
73
+ getInterfaceType.mockReturnValue('mouse')
74
+ const props = defaultProps()
121
75
  const { result } = renderHook(() => useKeyboardHint(props))
122
-
123
76
  act(() => result.current.handleFocus())
124
-
125
77
  expect(props.onViewportFocusChange).not.toHaveBeenCalled()
126
78
  })
127
79
 
128
- test('handleBlur always triggers false', () => {
80
+ test('handleBlur always calls onViewportFocusChange(false)', () => {
129
81
  const props = defaultProps({ interfaceType: 'mouse' })
130
82
  const { result } = renderHook(() => useKeyboardHint(props))
131
-
132
83
  act(() => result.current.handleBlur())
133
-
134
84
  expect(props.onViewportFocusChange).toHaveBeenCalledWith(false)
135
85
  })
136
86
  })
@@ -7,12 +7,13 @@ import { useService } from '../store/serviceContext.js'
7
7
 
8
8
  export function useKeyboardShortcuts (containerRef) {
9
9
  const { mapProvider, panDelta, nudgePanDelta, zoomDelta, nudgeZoomDelta, readMapText } = useConfig()
10
- const { interfaceType, dispatch } = useApp()
10
+ const { dispatch, layoutRefs } = useApp()
11
11
  const { announce } = useService()
12
12
 
13
13
  useEffect(() => {
14
14
  const el = containerRef.current
15
- if (!el || interfaceType !== 'keyboard') {
15
+ const appEl = layoutRefs.appContainerRef?.current
16
+ if (!el || !appEl) {
16
17
  return undefined
17
18
  }
18
19
 
@@ -59,16 +60,18 @@ export function useKeyboardShortcuts (containerRef) {
59
60
  const handleKeyDown = handle('keydown')
60
61
  const handleKeyUp = handle('keyup')
61
62
 
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.
62
66
  el.addEventListener('keydown', handleKeyDown)
63
- el.addEventListener('keyup', handleKeyUp)
67
+ appEl.addEventListener('keyup', handleKeyUp)
64
68
 
65
69
  return () => {
66
70
  el.removeEventListener('keydown', handleKeyDown)
67
- el.removeEventListener('keyup', handleKeyUp)
71
+ appEl.removeEventListener('keyup', handleKeyUp)
68
72
  }
69
73
  }, [
70
74
  containerRef,
71
- interfaceType,
72
75
  mapProvider,
73
76
  panDelta,
74
77
  nudgePanDelta,
@@ -15,6 +15,7 @@ jest.mock('../store/mapContext.js')
15
15
  jest.mock('../store/serviceContext.js')
16
16
 
17
17
  const setup = (overrides = {}) => {
18
+ const appContainerRef = { current: document.createElement('div') }
18
19
  useConfig.mockReturnValue({
19
20
  mapProvider: 'google',
20
21
  reverseGeocode: { showMarker: true },
@@ -25,7 +26,7 @@ const setup = (overrides = {}) => {
25
26
  readMapText: jest.fn(),
26
27
  ...overrides.config
27
28
  })
28
- useApp.mockReturnValue({ interfaceType: 'keyboard', dispatch: jest.fn(), ...overrides.app })
29
+ useApp.mockReturnValue({ dispatch: jest.fn(), layoutRefs: { appContainerRef }, ...overrides.app })
29
30
  useMap.mockReturnValue({ crossHair: { lat: 0, lng: 0 }, ...overrides.map })
30
31
  useService.mockReturnValue({ announce: jest.fn(), ...overrides.service })
31
32
 
@@ -34,20 +35,22 @@ const setup = (overrides = {}) => {
34
35
  keyboardMappings.keydown = overrides.keydown || {}
35
36
  keyboardMappings.keyup = overrides.keyup || {}
36
37
 
37
- return { containerRef: { current: document.createElement('div') }, actions }
38
+ return { containerRef: { current: document.createElement('div') }, appContainerRef, actions }
38
39
  }
39
40
 
40
41
  describe('useKeyboardShortcuts', () => {
41
42
  beforeEach(jest.clearAllMocks)
42
43
 
43
- test('early returns when no container or not keyboard interface', () => {
44
+ test('early returns when no container', () => {
44
45
  setup()
45
46
  renderHook(() => useKeyboardShortcuts({ current: null }))
46
47
  expect(createKeyboardActions).not.toHaveBeenCalled()
48
+ })
47
49
 
48
- setup({ app: { interfaceType: 'mouse' } })
49
- renderHook(() => useKeyboardShortcuts({ current: document.createElement('div') }))
50
- expect(createKeyboardActions).not.toHaveBeenCalled()
50
+ test('registers shortcuts regardless of interface type', () => {
51
+ const { containerRef } = setup()
52
+ renderHook(() => useKeyboardShortcuts(containerRef))
53
+ expect(createKeyboardActions).toHaveBeenCalled()
51
54
  })
52
55
 
53
56
  test('creates actions with correct config', () => {
@@ -121,7 +124,7 @@ describe('useKeyboardShortcuts', () => {
121
124
  })
122
125
 
123
126
  test('handles Alt+key combinations and keyup events', () => {
124
- const { containerRef, actions } = setup({
127
+ const { containerRef, appContainerRef, actions } = setup({
125
128
  keydown: { 'Alt+S': 'search' },
126
129
  keyup: { ArrowUp: 'stopPan' },
127
130
  actions: { search: jest.fn(), stopPan: jest.fn() }
@@ -131,7 +134,7 @@ describe('useKeyboardShortcuts', () => {
131
134
  containerRef.current.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyS', key: 's', altKey: true }))
132
135
  expect(actions.search).toHaveBeenCalled()
133
136
 
134
- containerRef.current.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowUp' }))
137
+ appContainerRef.current.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowUp' }))
135
138
  expect(actions.stopPan).toHaveBeenCalled()
136
139
  })
137
140
 
@@ -156,11 +159,12 @@ describe('useKeyboardShortcuts', () => {
156
159
  })
157
160
 
158
161
  test('cleanup removes event listeners', () => {
159
- const { containerRef } = setup()
160
- const spy = jest.spyOn(containerRef.current, 'removeEventListener')
162
+ const { containerRef, appContainerRef } = setup()
163
+ const viewportSpy = jest.spyOn(containerRef.current, 'removeEventListener')
164
+ const appSpy = jest.spyOn(appContainerRef.current, 'removeEventListener')
161
165
  const { unmount } = renderHook(() => useKeyboardShortcuts(containerRef))
162
166
  unmount()
163
- expect(spy).toHaveBeenCalledWith('keydown', expect.any(Function))
164
- expect(spy).toHaveBeenCalledWith('keyup', expect.any(Function))
167
+ expect(viewportSpy).toHaveBeenCalledWith('keydown', expect.any(Function))
168
+ expect(appSpy).toHaveBeenCalledWith('keyup', expect.any(Function))
165
169
  })
166
170
  })
@@ -55,7 +55,7 @@ function calculateLayout (layoutRefs) {
55
55
  const {
56
56
  appContainerRef, mainRef, topRef, topLeftColRef, topRightColRef,
57
57
  bottomRef, attributionsRef, bottomRightRef, leftTopRef, leftBottomRef,
58
- rightTopRef, rightBottomRef
58
+ rightTopRef, rightBottomRef, actionsRef
59
59
  } = layoutRefs
60
60
 
61
61
  const appContainer = appContainerRef.current
@@ -98,9 +98,15 @@ function calculateLayout (layoutRefs) {
98
98
  appContainer.style.setProperty('--right-top-max-height', `${rightColumnHeight}px`)
99
99
 
100
100
  // === Keyboard hint bottom offset ===
101
- // Distance from the bottom of im-o-app__bottom to the bottom of im-o-app__main.
102
- // Used to position the hint above the bottom bar (and above drawers on mobile).
103
- appContainer.style.setProperty('--keyboard-hint-bottom', `${main.offsetHeight - bottom.offsetTop - bottom.offsetHeight}px`)
101
+ // On mobile the actions bar is in-flow so baseBottom already accounts for it.
102
+ // On tablet/desktop the actions bar is position:absolute (not in flow), so baseBottom
103
+ // only sees the bottom padding. actionsOffset measures the gap from the actions bar's
104
+ // top edge to the bottom of main, ensuring the hint always clears the floating bar.
105
+ const actionsEl = actionsRef?.current
106
+ const actionsHeight = actionsEl?.offsetHeight ?? 0
107
+ const baseBottom = main.offsetHeight - bottom.offsetTop - bottom.offsetHeight
108
+ const actionsOffset = actionsHeight > 0 ? main.offsetHeight - actionsEl.offsetTop : 0
109
+ appContainer.style.setProperty('--hint-bottom', `${Math.max(baseBottom, actionsOffset + dividerGap)}px`)
104
110
 
105
111
  // === Sub-slot panel max-heights ===
106
112
  appContainer.style.setProperty('--left-top-panel-max-height', `${subSlotMaxHeight(leftColumnHeight, buttonHeight(leftBottomRef), dividerGap)}px`)
@@ -100,6 +100,36 @@ describe('useLayoutMeasurements', () => {
100
100
  expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith(varName, expected)
101
101
  })
102
102
 
103
+ test.each([
104
+ [
105
+ 'no actions (height 0) — uses base gap',
106
+ { main: { offsetHeight: 500 }, bottom: { offsetTop: 400 }, actions: { offsetTop: 450, offsetHeight: 0 } },
107
+ '100px' // baseBottom = 500 - 400 - 0 = 100
108
+ ],
109
+ [
110
+ 'actions floating above base gap (tablet/desktop) — uses actionsOffset + dividerGap',
111
+ { main: { offsetHeight: 500 }, bottom: { offsetTop: 440, offsetHeight: 20 }, actions: { offsetTop: 408, offsetHeight: 60 } },
112
+ '100px' // actionsOffset = 500 - 408 = 92, 92 + 8 = 100 > baseBottom (40)
113
+ ],
114
+ [
115
+ 'base gap already larger than actionsOffset (mobile in-flow) — base gap wins',
116
+ { main: { offsetHeight: 500 }, bottom: { offsetTop: 350 }, actions: { offsetTop: 430, offsetHeight: 40 } },
117
+ '150px' // baseBottom = 150 > actionsOffset (70) + dividerGap (8) = 78
118
+ ]
119
+ ])('calculates --hint-bottom for %s', (_, refOverrides, expected) => {
120
+ const { layoutRefs } = setup({ refs: refOverrides })
121
+ renderHook(() => useLayoutMeasurements())
122
+ expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith('--hint-bottom', expected)
123
+ })
124
+
125
+ test('calculates --hint-bottom when actionsRef.current is null', () => {
126
+ const { layoutRefs } = setup()
127
+ layoutRefs.actionsRef.current = null
128
+ renderHook(() => useLayoutMeasurements())
129
+ // actionsHeight = 0, falls back to baseBottom = 500 - 400 - 0 = 100
130
+ expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith('--hint-bottom', '100px')
131
+ })
132
+
103
133
  test.each([
104
134
  [{ offsetWidth: 250 }, { offsetWidth: 200 }, '250px'],
105
135
  [{ offsetWidth: 0 }, { offsetWidth: 200 }, '200px'],
@@ -0,0 +1,33 @@
1
+ import { useEffect } from 'react'
2
+ import { isStandaloneLabel } from '../../utils/symbolUtils.js'
3
+
4
+ export const useMarkerCursor = (markers, interactActive, viewportRef) => {
5
+ useEffect(() => {
6
+ if (!interactActive) {
7
+ return undefined
8
+ }
9
+ const viewport = viewportRef.current
10
+ if (!viewport) {
11
+ return undefined
12
+ }
13
+ const onMove = (e) => {
14
+ const hit = markers.items.some(marker => {
15
+ if (isStandaloneLabel(marker)) {
16
+ return false
17
+ }
18
+ const el = markers.markerRefs?.get(marker.id)
19
+ if (!el) {
20
+ return false
21
+ }
22
+ const { left, top, right, bottom } = el.getBoundingClientRect()
23
+ return e.clientX >= left && e.clientX <= right && e.clientY >= top && e.clientY <= bottom
24
+ })
25
+ viewport.style.cursor = hit ? 'pointer' : ''
26
+ }
27
+ viewport.addEventListener('mousemove', onMove)
28
+ return () => {
29
+ viewport.removeEventListener('mousemove', onMove)
30
+ viewport.style.cursor = ''
31
+ }
32
+ }, [markers, interactActive, viewportRef])
33
+ }