@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
@@ -1,11 +1,24 @@
1
- import { useEffect, useRef } from 'react'
1
+ import { useEffect } from 'react'
2
2
  import { EVENTS } from '../../../src/config/events.js'
3
3
  import { useInteractionHandlers } from './hooks/useInteractionHandlers.js'
4
+ import { useMapItemList } from './hooks/useMapItemList.js'
4
5
  import { useHighlightSync } from './hooks/useHighlightSync.js'
5
6
  import { useHoverCursor } from './hooks/useHoverCursor.js'
6
- import { attachEvents } from './events.js'
7
+ import { useCrossHairVisibility } from './hooks/useCrossHairVisibility.js'
8
+ import { useAttachEvents } from './hooks/useAttachEvents.js'
7
9
  import { isSelectMarkerOnly } from './utils/interactionModes.js'
8
10
 
11
+ function useListboxCapable ({ enabled, interactionModes, markers, layers, eventBus }) {
12
+ useEffect(() => {
13
+ if (!enabled) { return }
14
+ const hasLabeledMarkers = interactionModes?.includes('selectMarker') && markers.items.some(m => m.label)
15
+ const hasFeatureLayers = interactionModes?.includes('selectFeature') && layers.some(l => l.labelProperty)
16
+ if (hasLabeledMarkers || hasFeatureLayers) {
17
+ eventBus.emit('interact:listboxcapable')
18
+ }
19
+ }, [enabled, interactionModes, markers, layers, eventBus])
20
+ }
21
+
9
22
  export const InteractInit = ({
10
23
  appState,
11
24
  mapState,
@@ -14,14 +27,14 @@ export const InteractInit = ({
14
27
  mapProvider,
15
28
  pluginState
16
29
  }) => {
17
- const { interfaceType } = appState
18
- const { dispatch, enabled, selectedFeatures, selectionBounds, interactionModes, layers } = pluginState
30
+ const { dispatch, enabled, selectedFeatures, interactionModes, layers } = pluginState
19
31
  const { eventBus, closeApp } = services
20
- const { crossHair, mapStyle } = mapState
32
+ const { crossHair, mapStyle, markers } = mapState
21
33
 
22
- const isTouchOrKeyboard = ['touch', 'keyboard'].includes(interfaceType)
23
34
  const selectMarkerOnly = isSelectMarkerOnly(interactionModes)
24
35
 
36
+ useMapItemList({ mapState, pluginState, services, mapProvider })
37
+
25
38
  // Core interaction logic (click > select/marker)
26
39
  const { handleInteraction } = useInteractionHandlers({
27
40
  appState,
@@ -31,34 +44,12 @@ export const InteractInit = ({
31
44
  mapProvider
32
45
  })
33
46
 
34
- // Refs updated synchronously each render — keeps callbacks fresh without re-attaching events
35
- const handleInteractionRef = useRef(handleInteraction)
36
- handleInteractionRef.current = handleInteraction
37
-
38
- const pluginStateRef = useRef(pluginState)
39
- pluginStateRef.current = pluginState
40
-
41
- const appStateRef = useRef(appState)
42
- appStateRef.current = appState
43
-
44
- // Defer click handling by one macrotask so any click that triggered the enable
45
- // (e.g. finishing a draw gesture) fires before this handler is live.
46
- // Managed separately from attachEvents so re-runs of that effect don't reset it —
47
- // only resets when enabled actually changes.
48
- const clickReadyRef = useRef(false)
49
- useEffect(() => {
50
- clickReadyRef.current = false
51
- const timer = setTimeout(() => { clickReadyRef.current = true }, 0)
52
- return () => clearTimeout(timer)
53
- }, [pluginState.enabled])
54
-
55
47
  // Highlight features and sync state selectedBounds from mapProvider
56
48
  useHighlightSync({
57
49
  mapProvider,
58
50
  mapStyle,
59
51
  pluginState,
60
52
  selectedFeatures,
61
- selectionBounds,
62
53
  dispatch,
63
54
  events: EVENTS,
64
55
  eventBus
@@ -69,36 +60,13 @@ export const InteractInit = ({
69
60
  eventBus.emit('interact:active', { active: enabled, interactionModes })
70
61
  }, [enabled, interactionModes])
71
62
 
72
- useHoverCursor(mapProvider, enabled, interactionModes, layers)
73
-
74
- // Toggle target marker visibility
75
- useEffect(() => {
76
- if (enabled && isTouchOrKeyboard && !(interfaceType === 'touch' && selectMarkerOnly)) {
77
- crossHair.fixAtCenter()
78
- } else {
79
- crossHair.hide()
80
- }
81
- }, [enabled, interfaceType, interactionModes])
63
+ useListboxCapable({ enabled, interactionModes, markers, layers, eventBus })
82
64
 
83
- useEffect(() => {
84
- if (!pluginState.enabled) {
85
- return undefined // Explicit return
86
- }
65
+ useHoverCursor(mapProvider, enabled, interactionModes, layers)
87
66
 
88
- const cleanupEvents = attachEvents({
89
- getAppState: () => appStateRef.current,
90
- mapState,
91
- getPluginState: () => pluginStateRef.current,
92
- buttonConfig,
93
- events: EVENTS,
94
- eventBus,
95
- handleInteraction: (event) => handleInteractionRef.current(event),
96
- clickReadyRef,
97
- closeApp
98
- })
67
+ useCrossHairVisibility({ crossHair, enabled, selectMarkerOnly, appState })
99
68
 
100
- return cleanupEvents
101
- }, [pluginState.enabled, buttonConfig, eventBus, closeApp])
69
+ useAttachEvents({ pluginState, appState, mapState, buttonConfig, eventBus, handleInteraction, closeApp })
102
70
 
103
71
  return null
104
72
  }
@@ -1,48 +1,50 @@
1
- import { act, render } from '@testing-library/react'
1
+ import { render } from '@testing-library/react'
2
2
  import { EVENTS } from '../../../src/config/events.js'
3
3
  import { InteractInit } from './InteractInit.jsx'
4
4
  import { useInteractionHandlers } from './hooks/useInteractionHandlers.js'
5
5
  import { useHighlightSync } from './hooks/useHighlightSync.js'
6
6
  import { useHoverCursor } from './hooks/useHoverCursor.js'
7
- import { attachEvents } from './events.js'
7
+ import { useMapItemList } from './hooks/useMapItemList.js'
8
+
9
+ const LISTBOX_CAPABLE = 'interact:listboxcapable'
8
10
 
9
11
  jest.mock('./hooks/useInteractionHandlers.js')
10
12
  jest.mock('./hooks/useHighlightSync.js')
11
13
  jest.mock('./hooks/useHoverCursor.js')
12
- jest.mock('./events.js')
13
-
14
- describe('InteractInit', () => {
15
- let props
16
- let handleInteractionMock
17
- let cleanupMock
18
-
19
- beforeEach(() => {
20
- handleInteractionMock = jest.fn()
21
- cleanupMock = jest.fn()
22
-
23
- useInteractionHandlers.mockReturnValue({ handleInteraction: handleInteractionMock })
24
- useHighlightSync.mockReturnValue(undefined)
25
- useHoverCursor.mockReturnValue(undefined)
26
- attachEvents.mockReturnValue(cleanupMock)
27
-
28
- props = {
29
- appState: { interfaceType: 'mouse', layoutRefs: { viewportRef: { current: null } } },
30
- mapState: { crossHair: { fixAtCenter: jest.fn(), hide: jest.fn() }, mapStyle: {} },
31
- services: { eventBus: { emit: jest.fn() }, closeApp: jest.fn() },
32
- buttonConfig: {},
33
- mapProvider: { setHoverCursor: jest.fn() },
34
- pluginState: {
35
- dispatch: jest.fn(),
36
- enabled: true,
37
- selectedFeatures: [],
38
- selectedMarkers: [],
39
- selectionBounds: {},
40
- interactionModes: ['selectFeature'],
41
- layers: []
42
- }
14
+ jest.mock('./hooks/useMapItemList.js')
15
+ jest.mock('./hooks/useCrossHairVisibility.js')
16
+ jest.mock('./hooks/useAttachEvents.js')
17
+
18
+ let props
19
+ let handleInteractionMock
20
+
21
+ beforeEach(() => {
22
+ handleInteractionMock = jest.fn()
23
+
24
+ useInteractionHandlers.mockReturnValue({ handleInteraction: handleInteractionMock })
25
+ useHighlightSync.mockReturnValue(undefined)
26
+ useHoverCursor.mockReturnValue(undefined)
27
+ useMapItemList.mockReturnValue(undefined)
28
+
29
+ props = {
30
+ appState: { interfaceType: 'mouse', layoutRefs: { viewportRef: { current: document.createElement('div') }, appContainerRef: { current: document.createElement('div') } } },
31
+ mapState: { crossHair: { fixAtCenter: jest.fn(), hide: jest.fn() }, mapStyle: {} },
32
+ services: { eventBus: { emit: jest.fn() }, closeApp: jest.fn() },
33
+ buttonConfig: {},
34
+ mapProvider: { setHoverCursor: jest.fn() },
35
+ pluginState: {
36
+ dispatch: jest.fn(),
37
+ enabled: true,
38
+ selectedFeatures: [],
39
+ selectedMarkers: [],
40
+ selectionBounds: {},
41
+ interactionModes: ['selectFeature'],
42
+ layers: []
43
43
  }
44
- })
44
+ }
45
+ })
45
46
 
47
+ describe('InteractInit — hook delegation', () => {
46
48
  it('calls useInteractionHandlers with correct arguments', () => {
47
49
  render(<InteractInit {...props} />)
48
50
  expect(useInteractionHandlers).toHaveBeenCalledWith(expect.objectContaining({
@@ -66,44 +68,9 @@ describe('InteractInit', () => {
66
68
  eventBus: props.services.eventBus
67
69
  }))
68
70
  })
71
+ })
69
72
 
70
- it('fixes or hides crossHair based on interfaceType and enabled', () => {
71
- // enabled true + non-touch = hide
72
- render(<InteractInit {...props} />)
73
- expect(props.mapState.crossHair.hide).toHaveBeenCalled()
74
- expect(props.mapState.crossHair.fixAtCenter).not.toHaveBeenCalled()
75
-
76
- // touch interface
77
- props.appState.interfaceType = 'touch'
78
- render(<InteractInit {...props} />)
79
- expect(props.mapState.crossHair.fixAtCenter).toHaveBeenCalled()
80
- })
81
-
82
- it('attaches events and returns cleanup', () => {
83
- const { unmount } = render(<InteractInit {...props} />)
84
- expect(attachEvents).toHaveBeenCalledWith(expect.objectContaining({
85
- getAppState: expect.any(Function),
86
- getPluginState: expect.any(Function),
87
- handleInteraction: expect.any(Function),
88
- mapState: props.mapState,
89
- buttonConfig: props.buttonConfig,
90
- events: EVENTS,
91
- eventBus: props.services.eventBus,
92
- closeApp: props.services.closeApp
93
- }))
94
-
95
- const { getAppState, getPluginState, handleInteraction } = attachEvents.mock.calls.at(-1)[0]
96
- expect(getAppState()).toMatchObject(props.appState)
97
- expect(getPluginState()).toMatchObject({ enabled: props.pluginState.enabled })
98
-
99
- const event = { point: {}, coords: [] }
100
- handleInteraction(event)
101
- expect(handleInteractionMock).toHaveBeenCalledWith(event)
102
-
103
- unmount()
104
- expect(cleanupMock).toHaveBeenCalled()
105
- })
106
-
73
+ describe('InteractInit event bus emissions', () => {
107
74
  it('emits interact:active with active state and interactionModes on enable', () => {
108
75
  render(<InteractInit {...props} />)
109
76
  expect(props.services.eventBus.emit).toHaveBeenCalledWith('interact:active', {
@@ -112,20 +79,31 @@ describe('InteractInit', () => {
112
79
  })
113
80
  })
114
81
 
115
- it('enables click handling after a macrotask', () => {
116
- jest.useFakeTimers()
117
- render(<InteractInit {...props} />)
118
- act(() => jest.runAllTimers())
119
- jest.useRealTimers()
82
+ it('emits interact:listboxcapable when enabled with a feature layer that has a labelProperty', () => {
83
+ const capableProps = {
84
+ ...props,
85
+ pluginState: { ...props.pluginState, interactionModes: ['selectFeature'], layers: [{ layerId: 'myLayer', labelProperty: 'name' }] }
86
+ }
87
+ render(<InteractInit {...capableProps} />)
88
+ expect(capableProps.services.eventBus.emit).toHaveBeenCalledWith(LISTBOX_CAPABLE)
89
+ })
90
+
91
+ it('emits interact:listboxcapable when enabled with a labeled marker', () => {
92
+ const capableProps = {
93
+ ...props,
94
+ mapState: { ...props.mapState, markers: { items: [{ id: 'm1', label: 'My marker' }] } },
95
+ pluginState: { ...props.pluginState, interactionModes: ['selectMarker'] }
96
+ }
97
+ render(<InteractInit {...capableProps} />)
98
+ expect(capableProps.services.eventBus.emit).toHaveBeenCalledWith(LISTBOX_CAPABLE)
120
99
  })
121
100
 
122
- it('does not attach events if plugin not enabled', () => {
101
+ it('does not emit interact:listboxcapable when disabled', () => {
123
102
  const disabledProps = {
124
103
  ...props,
125
- pluginState: { ...props.pluginState, enabled: false } // fresh object
104
+ pluginState: { ...props.pluginState, enabled: false, interactionModes: ['selectFeature'], layers: [{ layerId: 'myLayer', labelProperty: 'name' }] }
126
105
  }
127
- attachEvents.mockClear() // ensure previous calls don't interfere
128
106
  render(<InteractInit {...disabledProps} />)
129
- expect(attachEvents).not.toHaveBeenCalled()
107
+ expect(disabledProps.services.eventBus.emit).not.toHaveBeenCalledWith(LISTBOX_CAPABLE)
130
108
  })
131
109
  })
@@ -2,8 +2,6 @@ export const DEFAULTS = {
2
2
  tolerance: 10,
3
3
  interactionModes: ['selectMarker'],
4
4
  multiSelect: false,
5
- contiguous: false,
6
5
  deselectOnClickOutside: false,
7
- marker: {},
8
- selectedStrokeWidth: 3
6
+ marker: {}
9
7
  }
@@ -0,0 +1,46 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import { EVENTS } from '../../../../src/config/events.js'
3
+ import { attachEvents } from '../events.js'
4
+
5
+ export function useAttachEvents ({ pluginState, appState, mapState, buttonConfig, eventBus, handleInteraction, closeApp }) {
6
+ // Refs updated synchronously each render — keeps callbacks fresh without re-attaching events
7
+ const handleInteractionRef = useRef(handleInteraction)
8
+ handleInteractionRef.current = handleInteraction
9
+
10
+ const pluginStateRef = useRef(pluginState)
11
+ pluginStateRef.current = pluginState
12
+
13
+ const appStateRef = useRef(appState)
14
+ appStateRef.current = appState
15
+
16
+ // Defer click handling by one macrotask so any click that triggered the enable
17
+ // (e.g. finishing a draw gesture) fires before this handler is live.
18
+ // Managed separately from attachEvents so re-runs of that effect don't reset it —
19
+ // only resets when enabled actually changes.
20
+ const clickReadyRef = useRef(false)
21
+ useEffect(() => {
22
+ clickReadyRef.current = false
23
+ const timer = setTimeout(() => { clickReadyRef.current = true }, 0)
24
+ return () => clearTimeout(timer)
25
+ }, [pluginState.enabled])
26
+
27
+ useEffect(() => {
28
+ if (!pluginState.enabled) {
29
+ return undefined
30
+ }
31
+
32
+ const cleanupEvents = attachEvents({
33
+ getAppState: () => appStateRef.current,
34
+ mapState,
35
+ getPluginState: () => pluginStateRef.current,
36
+ buttonConfig,
37
+ events: EVENTS,
38
+ eventBus,
39
+ handleInteraction: (event) => handleInteractionRef.current(event),
40
+ clickReadyRef,
41
+ closeApp
42
+ })
43
+
44
+ return cleanupEvents
45
+ }, [pluginState.enabled, buttonConfig, eventBus, closeApp])
46
+ }
@@ -0,0 +1,65 @@
1
+ import { act, renderHook } from '@testing-library/react'
2
+ import { useAttachEvents } from './useAttachEvents.js'
3
+ import { attachEvents } from '../events.js'
4
+ import { EVENTS } from '../../../../src/config/events.js'
5
+
6
+ jest.mock('../events.js')
7
+
8
+ let props
9
+ let cleanupMock
10
+ let handleInteractionMock
11
+
12
+ beforeEach(() => {
13
+ jest.clearAllMocks()
14
+ cleanupMock = jest.fn()
15
+ handleInteractionMock = jest.fn()
16
+ attachEvents.mockReturnValue(cleanupMock)
17
+
18
+ props = {
19
+ pluginState: { enabled: true },
20
+ appState: { interfaceType: 'mouse' },
21
+ mapState: { mapStyle: {} },
22
+ buttonConfig: {},
23
+ eventBus: { emit: jest.fn() },
24
+ closeApp: jest.fn(),
25
+ handleInteraction: handleInteractionMock
26
+ }
27
+ })
28
+
29
+ describe('useAttachEvents', () => {
30
+ it('attaches events and returns cleanup', () => {
31
+ const { unmount } = renderHook(() => useAttachEvents(props))
32
+ expect(attachEvents).toHaveBeenCalledWith(expect.objectContaining({
33
+ getAppState: expect.any(Function),
34
+ getPluginState: expect.any(Function),
35
+ handleInteraction: expect.any(Function),
36
+ mapState: props.mapState,
37
+ buttonConfig: props.buttonConfig,
38
+ events: EVENTS,
39
+ eventBus: props.eventBus,
40
+ closeApp: props.closeApp
41
+ }))
42
+
43
+ const { getAppState, getPluginState, handleInteraction } = attachEvents.mock.calls.at(-1)[0]
44
+ expect(getAppState()).toBe(props.appState)
45
+ expect(getPluginState()).toMatchObject({ enabled: props.pluginState.enabled })
46
+
47
+ handleInteraction({ point: {}, coords: [] })
48
+ expect(handleInteractionMock).toHaveBeenCalled()
49
+
50
+ unmount()
51
+ expect(cleanupMock).toHaveBeenCalled()
52
+ })
53
+
54
+ it('enables click handling after a macrotask', () => {
55
+ jest.useFakeTimers()
56
+ renderHook(() => useAttachEvents(props))
57
+ act(() => jest.runAllTimers())
58
+ jest.useRealTimers()
59
+ })
60
+
61
+ it('does not attach events if plugin not enabled', () => {
62
+ renderHook(() => useAttachEvents({ ...props, pluginState: { enabled: false } }))
63
+ expect(attachEvents).not.toHaveBeenCalled()
64
+ })
65
+ })
@@ -0,0 +1,47 @@
1
+ import { useCallback, useEffect, useRef } from 'react'
2
+ import { getInterfaceType, subscribeToInterfaceChangesImmediate } from '../../../../src/utils/detectInterfaceType.js'
3
+
4
+ export function useCrossHairVisibility ({ crossHair, enabled, selectMarkerOnly, appState }) {
5
+ const enabledRef = useRef(enabled)
6
+ enabledRef.current = enabled
7
+ const selectMarkerOnlyRef = useRef(selectMarkerOnly)
8
+ selectMarkerOnlyRef.current = selectMarkerOnly
9
+ const crossHairRef = useRef(crossHair)
10
+ crossHairRef.current = crossHair
11
+ const listboxFocusRef = useRef(false)
12
+
13
+ const updateCrossHair = useCallback(() => {
14
+ const type = getInterfaceType()
15
+ const isToK = ['touch', 'keyboard'].includes(type)
16
+ if (enabledRef.current && !listboxFocusRef.current && isToK && !(type === 'touch' && selectMarkerOnlyRef.current)) {
17
+ crossHairRef.current.fixAtCenter()
18
+ } else {
19
+ crossHairRef.current.hide()
20
+ }
21
+ }, [])
22
+
23
+ // Toggle target marker visibility on enabled/interactionModes changes
24
+ useEffect(() => {
25
+ updateCrossHair()
26
+ }, [enabled, selectMarkerOnly, updateCrossHair])
27
+
28
+ // Toggle target marker visibility immediately on interface type change (no 150ms React delay)
29
+ useEffect(() => {
30
+ return subscribeToInterfaceChangesImmediate(updateCrossHair)
31
+ }, [updateCrossHair])
32
+
33
+ // Hide crosshair when listbox has focus
34
+ useEffect(() => {
35
+ const container = appState.layoutRefs?.appContainerRef?.current
36
+ if (!container) { return undefined }
37
+ const handleFocusIn = (e) => {
38
+ const inListbox = !!e.target.closest('[role="listbox"], [role="option"]')
39
+ if (listboxFocusRef.current !== inListbox) {
40
+ listboxFocusRef.current = inListbox
41
+ updateCrossHair()
42
+ }
43
+ }
44
+ container.addEventListener('focusin', handleFocusIn)
45
+ return () => { container.removeEventListener('focusin', handleFocusIn) }
46
+ }, [appState.layoutRefs, updateCrossHair])
47
+ }
@@ -0,0 +1,54 @@
1
+ import { act, renderHook } from '@testing-library/react'
2
+ import { useCrossHairVisibility } from './useCrossHairVisibility.js'
3
+ import { getInterfaceType } from '../../../../src/utils/detectInterfaceType.js'
4
+
5
+ jest.mock('../../../../src/utils/detectInterfaceType.js', () => ({
6
+ getInterfaceType: jest.fn(() => 'mouse'),
7
+ subscribeToInterfaceChangesImmediate: jest.fn(() => () => {})
8
+ }))
9
+
10
+ let crossHair
11
+ let appState
12
+
13
+ beforeEach(() => {
14
+ jest.clearAllMocks()
15
+ crossHair = { fixAtCenter: jest.fn(), hide: jest.fn() }
16
+ appState = { layoutRefs: { appContainerRef: { current: document.createElement('div') } } }
17
+ getInterfaceType.mockReturnValue('mouse')
18
+ })
19
+
20
+ describe('useCrossHairVisibility', () => {
21
+ it('skips listbox focus listeners when appContainerRef is null', () => {
22
+ appState.layoutRefs.appContainerRef = { current: null }
23
+ renderHook(() => useCrossHairVisibility({ crossHair, enabled: true, selectMarkerOnly: false, appState }))
24
+ expect(crossHair.hide).toHaveBeenCalled()
25
+ })
26
+
27
+ it('shows crosshair on touch/keyboard and hides when listbox has focus', () => {
28
+ const container = appState.layoutRefs.appContainerRef.current
29
+ getInterfaceType.mockReturnValue('touch')
30
+ renderHook(() => useCrossHairVisibility({ crossHair, enabled: true, selectMarkerOnly: false, appState }))
31
+
32
+ expect(crossHair.fixAtCenter).toHaveBeenCalled()
33
+
34
+ // Focus moves into listbox — hide
35
+ const listboxEl = document.createElement('div')
36
+ listboxEl.setAttribute('role', 'listbox')
37
+ container.appendChild(listboxEl)
38
+ act(() => listboxEl.dispatchEvent(new FocusEvent('focusin', { bubbles: true })))
39
+ expect(crossHair.hide).toHaveBeenCalled()
40
+
41
+ // Second focusin still inside listbox — no-op (state unchanged)
42
+ const listboxEl2 = document.createElement('div')
43
+ listboxEl2.setAttribute('role', 'listbox')
44
+ container.appendChild(listboxEl2)
45
+ act(() => listboxEl2.dispatchEvent(new FocusEvent('focusin', { bubbles: true })))
46
+ expect(crossHair.hide).toHaveBeenCalledTimes(1)
47
+
48
+ // Focus moves back out of listbox — show again
49
+ const otherEl = document.createElement('div')
50
+ container.appendChild(otherEl)
51
+ act(() => otherEl.dispatchEvent(new FocusEvent('focusin', { bubbles: true })))
52
+ expect(crossHair.fixAtCenter).toHaveBeenCalledTimes(2)
53
+ })
54
+ })
@@ -1,6 +1,18 @@
1
1
  import { useEffect, useMemo } from 'react'
2
2
  import { buildStylesMap } from '../utils/buildStylesMap.js'
3
3
 
4
+ /**
5
+ * Keeps map highlight rendering in sync with the current selection and keyboard-active item.
6
+ *
7
+ * Calls mapProvider.updateHighlightedFeatures whenever selectedFeatures or listboxActiveItem
8
+ * changes, passing selected features (shown with the selection ring) and the active item
9
+ * (shown with the keyboard cursor ring) as separate arguments so the provider can style them
10
+ * differently. Also re-applies highlights after a map style reload, since highlight layers
11
+ * are removed when the base style changes.
12
+ *
13
+ * Dispatches UPDATE_SELECTED_BOUNDS with the bounding box returned by the provider so
14
+ * downstream consumers (e.g. interact:done) receive up-to-date bounds.
15
+ */
4
16
  export const useHighlightSync = ({
5
17
  mapProvider,
6
18
  mapStyle,
@@ -10,7 +22,7 @@ export const useHighlightSync = ({
10
22
  events,
11
23
  eventBus
12
24
  }) => {
13
- const { layers } = pluginState
25
+ const { layers, listboxActiveItem } = pluginState
14
26
 
15
27
  // Memoize stylesMap so it only recalculates when style or layers change
16
28
  const stylesMap = useMemo(() => {
@@ -22,7 +34,10 @@ export const useHighlightSync = ({
22
34
 
23
35
  // Force re-application of all selected features
24
36
  const updateHighlightedFeatures = () => {
25
- const bounds = mapProvider.updateHighlightedFeatures?.(selectedFeatures, stylesMap)
37
+ const activeFeatures = listboxActiveItem
38
+ ? [{ featureId: listboxActiveItem.featureId, layerId: listboxActiveItem.layerId, idProperty: listboxActiveItem.idProperty, geometry: listboxActiveItem.geometry }]
39
+ : []
40
+ const bounds = mapProvider.updateHighlightedFeatures?.(selectedFeatures, activeFeatures, stylesMap)
26
41
 
27
42
  dispatch({
28
43
  type: 'UPDATE_SELECTED_BOUNDS',
@@ -31,18 +46,25 @@ export const useHighlightSync = ({
31
46
  }
32
47
 
33
48
  useEffect(() => {
34
- if (!mapProvider || !selectedFeatures || !stylesMap) {
49
+ if (!mapProvider || !stylesMap) {
35
50
  return undefined // Explicit return to match the cleanup function return below
36
51
  }
37
52
 
38
- // Update updateHighlightedFeatures on interaction
39
53
  updateHighlightedFeatures()
40
54
 
41
- // Update updateHighlightedFeatures on stylechange
42
- eventBus.on(events.MAP_DATA_CHANGE, updateHighlightedFeatures)
55
+ // Re-apply after style reload — highlight layers are removed when style reloads.
56
+ // MAP_STYLE_CHANGE fires as soon as the new style is ready, before any plugin has
57
+ // re-added its layers, so we register a one-time MAP_DATA_CHANGE listener instead.
58
+ // MAP_DATA_CHANGE is debounced and fires once all layer additions from every plugin
59
+ // have settled, giving us a single well-timed trigger. Using once() avoids the
60
+ // infinite loop a permanent listener would cause (layer moves re-trigger MAP_DATA_CHANGE).
61
+ const handleStyleChange = () => {
62
+ eventBus.once(events.MAP_DATA_CHANGE, updateHighlightedFeatures)
63
+ }
64
+ eventBus.on(events.MAP_STYLE_CHANGE, handleStyleChange)
43
65
 
44
66
  return () => {
45
- eventBus.off(events.MAP_DATA_CHANGE, updateHighlightedFeatures)
67
+ eventBus.off(events.MAP_STYLE_CHANGE, handleStyleChange)
46
68
  }
47
- }, [selectedFeatures, mapProvider, stylesMap])
69
+ }, [selectedFeatures, listboxActiveItem, mapProvider, stylesMap, eventBus])
48
70
  }