@defra/interactive-map 0.0.22-alpha → 0.0.24-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 (242) hide show
  1. package/assets/images/light-raster-thumb.jpg +0 -0
  2. package/assets/images/outdoor-raster-thumb.jpg +0 -0
  3. package/assets/images/road-raster-thumb.jpg +0 -0
  4. package/dist/css/index.css +1 -1
  5. package/dist/esm/im-core.js +1 -1
  6. package/dist/esm/im-shell.js +1 -1
  7. package/dist/umd/im-core.js +1 -1
  8. package/dist/umd/index.js +1 -1
  9. package/docs/api/button-definition.md +39 -0
  10. package/docs/api/context.md +4 -4
  11. package/docs/api/map-style-config.md +11 -2
  12. package/docs/api/marker-config.md +38 -7
  13. package/docs/api/panel-definition.md +7 -3
  14. package/docs/api/symbol-config.md +10 -26
  15. package/docs/api/symbol-registry.md +28 -13
  16. package/docs/api.md +24 -2
  17. package/docs/assets/css/{docusaurus.css → docusaurus.scss} +18 -12
  18. package/docs/assets/images/toggle-marker-label.jpg +0 -0
  19. package/docs/examples/index.mdx +12 -0
  20. package/docs/examples/toggle-marker-label.mdx +89 -0
  21. package/docs/plugins/interact.md +4 -11
  22. package/docs/plugins/search.md +70 -0
  23. package/docusaurus.config.cjs +2 -1
  24. package/package.json +23 -9
  25. package/plugins/beta/datasets/dist/adapters/maplibre/esm/im-datasets-ml-adapter.js +1 -1
  26. package/plugins/beta/datasets/dist/css/index.css +2 -2
  27. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  28. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-maplibre-adapter.js +1 -1
  29. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-plugin.js +1 -1
  30. package/plugins/beta/datasets/dist/umd/maplibre/index.js +1 -1
  31. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +5 -5
  32. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +24 -29
  33. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +2 -2
  34. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +3 -3
  35. package/plugins/beta/datasets/src/components/KeySvg.jsx +2 -2
  36. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +1 -2
  37. package/plugins/beta/datasets/src/components/svgProperties.js +2 -2
  38. package/plugins/beta/datasets/src/panels/Key.module.scss +2 -2
  39. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  40. package/plugins/beta/draw-ml/dist/css/index.css +1 -21
  41. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  42. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  43. package/plugins/beta/draw-ml/src/api/editFeature.js +9 -4
  44. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +11 -3
  45. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  46. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  47. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  48. package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
  49. package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
  50. package/plugins/beta/use-location/src/manifest.js +1 -0
  51. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  52. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  53. package/plugins/interact/dist/umd/index.js +1 -1
  54. package/plugins/interact/src/InteractInit.jsx +23 -55
  55. package/plugins/interact/src/InteractInit.test.js +58 -80
  56. package/plugins/interact/src/defaults.js +1 -3
  57. package/plugins/interact/src/hooks/useAttachEvents.js +46 -0
  58. package/plugins/interact/src/hooks/useAttachEvents.test.js +68 -0
  59. package/plugins/interact/src/hooks/useCrossHairVisibility.js +47 -0
  60. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +54 -0
  61. package/plugins/interact/src/hooks/useHighlightSync.js +30 -8
  62. package/plugins/interact/src/hooks/useHighlightSync.test.js +127 -80
  63. package/plugins/interact/src/hooks/useInteractionHandlers.js +18 -17
  64. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +22 -61
  65. package/plugins/interact/src/hooks/useMapItemList.js +187 -0
  66. package/plugins/interact/src/hooks/useMapItemList.test.js +584 -0
  67. package/plugins/interact/src/index.js +0 -2
  68. package/plugins/interact/src/index.test.js +0 -2
  69. package/plugins/interact/src/manifest.js +37 -6
  70. package/plugins/interact/src/reducer.js +8 -4
  71. package/plugins/interact/src/reducer.test.js +18 -3
  72. package/plugins/interact/src/utils/buildStylesMap.js +21 -10
  73. package/plugins/interact/src/utils/buildStylesMap.test.js +29 -29
  74. package/plugins/interact/src/utils/spatial.js +0 -15
  75. package/plugins/interact/src/utils/spatial.test.js +1 -13
  76. package/plugins/search/dist/css/index.css +1 -1
  77. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  78. package/plugins/search/dist/esm/index.js +1 -1
  79. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  80. package/plugins/search/dist/umd/index.js +1 -1
  81. package/plugins/search/src/Search.jsx +2 -1
  82. package/plugins/search/src/components/CloseButton/CloseButton.module.scss +2 -0
  83. package/plugins/search/src/components/Form/Form.jsx +11 -7
  84. package/plugins/search/src/components/Form/Form.test.jsx +23 -1
  85. package/plugins/search/src/components/OpenButton/OpenButton.jsx +1 -1
  86. package/plugins/search/src/components/OpenButton/OpenButton.module.scss +3 -0
  87. package/plugins/search/src/datasets.js +2 -2
  88. package/plugins/search/src/datasets.test.js +1 -1
  89. package/plugins/search/src/defaults.js +7 -1
  90. package/plugins/search/src/events/fetchSuggestions.js +13 -10
  91. package/plugins/search/src/events/fetchSuggestions.test.js +14 -1
  92. package/plugins/search/src/events/formHandlers.js +2 -4
  93. package/plugins/search/src/events/formHandlers.test.js +3 -3
  94. package/plugins/search/src/events/inputHandlers.js +1 -1
  95. package/plugins/search/src/events/inputHandlers.test.js +1 -1
  96. package/plugins/search/src/events/suggestionHandlers.js +2 -1
  97. package/plugins/search/src/events/suggestionHandlers.test.js +7 -1
  98. package/plugins/search/src/index.js +3 -7
  99. package/plugins/search/src/reducer.js +10 -5
  100. package/plugins/search/src/reducer.test.js +2 -1
  101. package/plugins/search/src/search.scss +1 -0
  102. package/plugins/search/src/utils/parseOsNamesResults.js +3 -3
  103. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  104. package/providers/beta/esri/src/esriProvider.js +4 -0
  105. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -0
  106. package/providers/beta/openlayers/dist/esm/index.js +1 -0
  107. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +2 -0
  108. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js.LICENSE.txt +1 -0
  109. package/providers/beta/openlayers/dist/umd/index.js +2 -0
  110. package/providers/beta/openlayers/dist/umd/index.js.LICENSE.txt +1 -0
  111. package/providers/beta/openlayers/src/appEvents.js +50 -0
  112. package/providers/beta/openlayers/src/appEvents.test.js +140 -0
  113. package/providers/beta/openlayers/src/defaults.js +36 -0
  114. package/providers/beta/openlayers/src/index.js +21 -0
  115. package/providers/beta/openlayers/src/mapEvents.js +132 -0
  116. package/providers/beta/openlayers/src/mapEvents.test.js +197 -0
  117. package/providers/beta/openlayers/src/openlayersProvider.js +234 -0
  118. package/providers/beta/openlayers/src/openlayersProvider.test.js +331 -0
  119. package/providers/beta/openlayers/src/utils/spatial.js +142 -0
  120. package/providers/beta/openlayers/src/utils/spatial.test.js +117 -0
  121. package/providers/beta/openlayers/src/utils/zoom.js +26 -0
  122. package/providers/beta/openlayers/src/utils/zoom.test.js +23 -0
  123. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  124. package/providers/maplibre/dist/esm/index.js +1 -1
  125. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  126. package/providers/maplibre/dist/umd/index.js +1 -1
  127. package/providers/maplibre/src/index.js +1 -1
  128. package/providers/maplibre/src/index.test.js +9 -14
  129. package/providers/maplibre/src/mapEvents.js +11 -5
  130. package/providers/maplibre/src/mapEvents.test.js +14 -3
  131. package/providers/maplibre/src/maplibreProvider.js +18 -11
  132. package/providers/maplibre/src/maplibreProvider.test.js +43 -36
  133. package/providers/maplibre/src/utils/highlightFeatures.js +112 -57
  134. package/providers/maplibre/src/utils/highlightFeatures.test.js +179 -51
  135. package/providers/maplibre/src/utils/maplibreFixes.js +10 -0
  136. package/providers/maplibre/src/utils/maplibreFixes.test.js +31 -0
  137. package/providers/maplibre/src/utils/patternImages.js +22 -19
  138. package/providers/maplibre/src/utils/patternImages.test.js +146 -167
  139. package/providers/maplibre/src/utils/rasteriseToImageData.js +1 -4
  140. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +23 -24
  141. package/providers/maplibre/src/utils/symbolImages.js +23 -86
  142. package/providers/maplibre/src/utils/symbolImages.test.js +60 -105
  143. package/rollup.esm.mjs +14 -3
  144. package/src/App/components/Actions/Actions.module.scss +1 -0
  145. package/src/App/components/Actions/Actions.test.jsx +17 -4
  146. package/src/App/components/Hints/Hints.jsx +52 -0
  147. package/src/App/components/Hints/Hints.module.scss +37 -0
  148. package/src/App/components/Hints/Hints.test.jsx +104 -0
  149. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +57 -19
  150. package/src/App/components/KeyboardHelp/KeyboardHelp.module.scss +10 -0
  151. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +140 -31
  152. package/src/App/components/Markers/LabelMarker.jsx +14 -0
  153. package/src/App/components/Markers/LabelMarker.test.jsx +36 -0
  154. package/src/App/components/Markers/Markers.jsx +44 -75
  155. package/src/App/components/Markers/Markers.module.scss +58 -0
  156. package/src/App/components/Markers/Markers.test.jsx +90 -113
  157. package/src/App/components/Markers/SymbolLabelMarker.jsx +43 -0
  158. package/src/App/components/Markers/SymbolLabelMarker.test.jsx +56 -0
  159. package/src/App/components/Markers/SymbolMarker.jsx +31 -0
  160. package/src/App/components/Markers/SymbolMarker.test.jsx +72 -0
  161. package/src/App/components/Panel/Panel.jsx +1 -1
  162. package/src/App/components/Panel/Panel.module.scss +1 -0
  163. package/src/App/components/PopupMenu/PopupMenu.test.jsx +117 -1
  164. package/src/App/components/PopupMenu/usePopupMenu.js +132 -85
  165. package/src/App/components/Tabs/Tabs.jsx +68 -0
  166. package/src/App/components/Tabs/Tabs.module.scss +57 -0
  167. package/src/App/components/Tabs/Tabs.test.jsx +121 -0
  168. package/src/App/components/Tooltip/Tooltip.module.scss +1 -1
  169. package/src/App/components/Viewport/Features.jsx +35 -0
  170. package/src/App/components/Viewport/Features.module.scss +31 -0
  171. package/src/App/components/Viewport/Features.test.jsx +124 -0
  172. package/src/App/components/Viewport/MapStatus.jsx +0 -4
  173. package/src/App/components/Viewport/Viewport.jsx +35 -29
  174. package/src/App/components/Viewport/Viewport.module.scss +7 -21
  175. package/src/App/components/Viewport/Viewport.test.jsx +108 -65
  176. package/src/App/controls/keyboardActions.js +9 -3
  177. package/src/App/controls/keyboardActions.test.js +122 -87
  178. package/src/App/controls/keyboardShortcuts.js +1 -14
  179. package/src/App/hooks/useFeatureFocus.js +199 -0
  180. package/src/App/hooks/useFeatureFocus.test.js +635 -0
  181. package/src/App/hooks/useFeatureItems.js +30 -0
  182. package/src/App/hooks/useFeatureItems.test.js +98 -0
  183. package/src/App/hooks/useFocusVisible.js +4 -7
  184. package/src/App/hooks/useFocusVisible.test.js +6 -6
  185. package/src/App/hooks/useKeyboardHint.js +9 -34
  186. package/src/App/hooks/useKeyboardHint.test.js +34 -84
  187. package/src/App/hooks/useKeyboardShortcuts.js +8 -5
  188. package/src/App/hooks/useKeyboardShortcuts.test.js +16 -12
  189. package/src/App/hooks/useLayoutMeasurements.js +10 -4
  190. package/src/App/hooks/useLayoutMeasurements.test.js +30 -1
  191. package/src/App/hooks/useMarkerCursor.js +33 -0
  192. package/src/App/hooks/useMarkerCursor.test.js +87 -0
  193. package/src/App/hooks/useMarkersAPI.js +32 -9
  194. package/src/App/hooks/useMarkersAPI.test.js +163 -94
  195. package/src/App/hooks/useModalPanelBehaviour.test.js +2 -1
  196. package/src/App/layout/Layout.jsx +2 -0
  197. package/src/App/layout/Layout.test.jsx +3 -0
  198. package/src/App/registry/keyboardShortcutRegistry.js +3 -2
  199. package/src/App/registry/keyboardShortcutRegistry.test.js +6 -8
  200. package/src/App/renderer/mapButtons.js +5 -1
  201. package/src/App/renderer/mapButtons.test.js +37 -1
  202. package/src/App/store/AppProvider.test.jsx +5 -0
  203. package/src/App/store/MapProvider.test.jsx +8 -1
  204. package/src/App/store/ServiceProvider.jsx +4 -1
  205. package/src/App/store/ServiceProvider.test.jsx +11 -0
  206. package/src/App/store/appActionsMap.js +5 -1
  207. package/src/App/store/appActionsMap.test.js +13 -0
  208. package/src/App/store/appReducer.js +1 -0
  209. package/src/App/store/mapActionsMap.js +2 -1
  210. package/src/InteractiveMap/InteractiveMap.js +12 -0
  211. package/src/InteractiveMap/InteractiveMap.test.js +2 -0
  212. package/src/config/appConfig.js +3 -1
  213. package/src/config/appConfig.test.js +4 -0
  214. package/src/config/defaults.js +1 -1
  215. package/src/config/events.js +8 -0
  216. package/src/config/mapTheme.js +8 -4
  217. package/src/config/symbolConfig.js +23 -16
  218. package/src/scss/main.scss +3 -0
  219. package/src/scss/tools/_border-focus.scss +0 -1
  220. package/src/services/announcer.test.js +5 -7
  221. package/src/services/eventBus.js +15 -0
  222. package/src/services/eventBus.test.js +11 -0
  223. package/src/services/hints.js +47 -0
  224. package/src/services/hints.test.js +110 -0
  225. package/src/services/patternRegistry.js +69 -1
  226. package/src/services/patternRegistry.test.js +112 -1
  227. package/src/services/reverseGeocode.js +4 -0
  228. package/src/services/reverseGeocode.test.js +11 -1
  229. package/src/services/symbolRegistry.js +152 -17
  230. package/src/services/symbolRegistry.test.js +141 -31
  231. package/src/types.js +71 -14
  232. package/src/utils/constrainKeyboardFocus.js +5 -5
  233. package/src/utils/detectInterfaceType.js +27 -5
  234. package/src/utils/detectInterfaceType.test.js +29 -3
  235. package/src/utils/patternUtils.js +4 -67
  236. package/src/utils/patternUtils.test.js +1 -123
  237. package/src/utils/symbolUtils.js +11 -13
  238. package/src/utils/symbolUtils.test.js +18 -26
  239. package/webpack.dev.mjs +6 -1
  240. package/webpack.umd.mjs +1 -0
  241. package/plugins/interact/dist/css/index.css +0 -1
  242. package/plugins/interact/src/interact.scss +0 -24
@@ -1,12 +1,16 @@
1
1
  import { symbolRegistry } from './symbolRegistry.js'
2
- import { symbolDefaults } from '../config/symbolConfig.js'
3
- import { SCHEME_COLORS } from '../config/mapTheme.js'
2
+ import { symbolDefaults, pin } from '../config/symbolConfig.js'
3
+ import { THEME_COLORS } from '../config/mapTheme.js'
4
4
  import { getValueForStyle } from '../utils/getValueForStyle.js'
5
5
 
6
6
  const STYLE_ID = 'test'
7
7
  const mapStyle = { id: STYLE_ID }
8
+ const COLOR_OVERRIDE = '#ff0000'
9
+ const FILL_OVERRIDE = `fill="${COLOR_OVERRIDE}"`
8
10
 
9
11
  beforeEach(() => {
12
+ symbolRegistry.clear()
13
+ symbolRegistry.initialise()
10
14
  symbolRegistry.setDefaults({})
11
15
  })
12
16
 
@@ -58,20 +62,20 @@ describe('symbolRegistry — setDefaults / getDefaults', () => {
58
62
  })
59
63
 
60
64
  it('constructor defaults override hardcoded defaults', () => {
61
- symbolRegistry.setDefaults({ backgroundColor: '#ff0000', symbol: 'circle' })
65
+ symbolRegistry.setDefaults({ backgroundColor: COLOR_OVERRIDE, symbol: 'circle' })
62
66
  const defaults = symbolRegistry.getDefaults()
63
- expect(defaults.backgroundColor).toBe('#ff0000')
67
+ expect(defaults.backgroundColor).toBe(COLOR_OVERRIDE)
64
68
  expect(defaults.symbol).toBe('circle')
65
69
  })
66
70
 
67
71
  it('constructor defaults do not affect unset properties', () => {
68
- symbolRegistry.setDefaults({ backgroundColor: '#ff0000' })
72
+ symbolRegistry.setDefaults({ backgroundColor: COLOR_OVERRIDE })
69
73
  const defaults = symbolRegistry.getDefaults()
70
74
  expect(defaults.foregroundColor).toBe(symbolDefaults.foregroundColor)
71
75
  })
72
76
 
73
77
  it('setDefaults with null or undefined resets to hardcoded defaults', () => {
74
- symbolRegistry.setDefaults({ backgroundColor: '#ff0000' })
78
+ symbolRegistry.setDefaults({ backgroundColor: COLOR_OVERRIDE })
75
79
  symbolRegistry.setDefaults(null)
76
80
  expect(symbolRegistry.getDefaults().backgroundColor).toBe(symbolDefaults.backgroundColor)
77
81
  })
@@ -81,24 +85,23 @@ describe('symbolRegistry — resolve', () => {
81
85
  const BACKGROUND_SVG = '<path fill="{{backgroundColor}}"/>'
82
86
  const symbolDef = {
83
87
  id: 'test',
84
- svg: '<path fill="{{backgroundColor}}" stroke="{{haloColor}}" stroke-width="{{haloWidth}}"/><path fill="{{foregroundColor}}" stroke="{{selectedColor}}"/>'
88
+ svg: '<path fill="{{backgroundColor}}" stroke="{{haloColor}}"/><path fill="{{foregroundColor}}" stroke="{{selectedColor}}"/><path stroke="{{activeColor}}"/>'
85
89
  }
86
90
 
87
91
  it('injects default token values when no overrides given', () => {
88
92
  const resolved = symbolRegistry.resolve(symbolDef, {}, mapStyle)
89
93
  expect(resolved).toContain(`fill="${getValueForStyle(symbolDefaults.backgroundColor, STYLE_ID)}"`)
90
94
  expect(resolved).toContain(`fill="${getValueForStyle(symbolDefaults.foregroundColor, STYLE_ID)}"`)
91
- expect(resolved).toContain(`stroke-width="${symbolDefaults.haloWidth}"`)
92
95
  })
93
96
 
94
- it('always produces empty selectedColor tokenring is hidden', () => {
97
+ it('always produces empty selectedColor and activeColor tokens rings are hidden', () => {
95
98
  const resolved = symbolRegistry.resolve(symbolDef, {}, mapStyle)
96
- expect(resolved).toContain('stroke=""')
99
+ expect(resolved).toContain('stroke="none"')
97
100
  })
98
101
 
99
102
  it('uses light scheme haloColor when mapStyle has no haloColor', () => {
100
103
  const resolved = symbolRegistry.resolve(symbolDef, {}, mapStyle)
101
- expect(resolved).toContain(`stroke="${SCHEME_COLORS.light.haloColor}"`)
104
+ expect(resolved).toContain(`stroke="${THEME_COLORS.light.haloColor}"`)
102
105
  })
103
106
 
104
107
  it('uses mapStyle.haloColor when provided', () => {
@@ -107,8 +110,8 @@ describe('symbolRegistry — resolve', () => {
107
110
  })
108
111
 
109
112
  it('overrides default backgroundColor with a plain string', () => {
110
- const resolved = symbolRegistry.resolve(symbolDef, { backgroundColor: '#ff0000' }, mapStyle)
111
- expect(resolved).toContain('fill="#ff0000"')
113
+ const resolved = symbolRegistry.resolve(symbolDef, { backgroundColor: COLOR_OVERRIDE }, mapStyle)
114
+ expect(resolved).toContain(FILL_OVERRIDE)
112
115
  })
113
116
 
114
117
  it('overrides default with a style-keyed color', () => {
@@ -163,48 +166,70 @@ describe('symbolRegistry — resolve', () => {
163
166
  })
164
167
  })
165
168
 
166
- describe('symbolRegistry — resolveSelected', () => {
169
+ describe('symbolRegistry — resolveActive (keyboard cursor state)', () => {
167
170
  const symbolDef = {
168
- id: 'test-sel',
169
- svg: '<path stroke="{{selectedColor}}" stroke-width="{{selectedWidth}}"/><path fill="{{backgroundColor}}"/>'
171
+ id: 'test-active',
172
+ svg: '<path fill="{{selectedColor}}" stroke="{{activeColor}}" stroke-width="6"/><path fill="{{backgroundColor}}"/>'
170
173
  }
171
174
 
172
- it('uses light scheme selectedColor when mapStyle has no selectedColor', () => {
173
- const resolved = symbolRegistry.resolveSelected(symbolDef, {}, mapStyle)
174
- expect(resolved).toContain(`stroke="${SCHEME_COLORS.light.selectedColor}"`)
175
+ it('renders selectedColor from scheme when mapStyle has no selectedColor', () => {
176
+ const resolved = symbolRegistry.resolveActive(symbolDef, {}, mapStyle)
177
+ expect(resolved).toContain(`fill="${THEME_COLORS.light.selectedColor}"`)
178
+ })
179
+
180
+ it('renders activeColor from scheme when mapStyle has no activeColor', () => {
181
+ const resolved = symbolRegistry.resolveActive(symbolDef, {}, mapStyle)
182
+ expect(resolved).toContain(`stroke="${THEME_COLORS.light.activeColor}"`)
175
183
  })
176
184
 
177
185
  it('uses mapStyle.selectedColor when provided', () => {
178
- const resolved = symbolRegistry.resolveSelected(symbolDef, {}, { id: STYLE_ID, selectedColor: '#ff0000' })
179
- expect(resolved).toContain('stroke="#ff0000"')
186
+ const resolved = symbolRegistry.resolveActive(symbolDef, {}, { id: STYLE_ID, selectedColor: COLOR_OVERRIDE })
187
+ expect(resolved).toContain(FILL_OVERRIDE)
180
188
  })
181
189
 
182
- it('uses selectedWidth from symbolDefaults', () => {
183
- const resolved = symbolRegistry.resolveSelected(symbolDef, {}, mapStyle)
184
- expect(resolved).toContain(`stroke-width="${symbolDefaults.selectedWidth}"`)
190
+ it('uses mapStyle.activeColor when provided', () => {
191
+ const resolved = symbolRegistry.resolveActive(symbolDef, {}, { id: STYLE_ID, activeColor: '#00ff00' })
192
+ expect(resolved).toContain('stroke="#00ff00"')
185
193
  })
186
194
 
187
195
  it('handles null styleColors — uses cascade defaults', () => {
188
- const resolved = symbolRegistry.resolveSelected(symbolDef, null, mapStyle)
189
- expect(resolved).toContain(`stroke="${SCHEME_COLORS.light.selectedColor}"`)
196
+ const resolved = symbolRegistry.resolveActive(symbolDef, null, mapStyle)
197
+ expect(resolved).toContain(`fill="${THEME_COLORS.light.selectedColor}"`)
190
198
  })
191
199
 
192
200
  it('returns empty string for null symbolDef', () => {
193
- expect(symbolRegistry.resolveSelected(null, {}, mapStyle)).toBe('')
201
+ expect(symbolRegistry.resolveActive(null, {}, mapStyle)).toBe('')
194
202
  })
195
203
 
196
204
  it('symbol-level selectedColor is ignored — mapStyle wins', () => {
197
205
  const defWithSelected = { ...symbolDef, selectedColor: '#00ff00' }
198
- const resolved = symbolRegistry.resolveSelected(defWithSelected, {}, { id: STYLE_ID, selectedColor: '#ff0000' })
199
- expect(resolved).toContain('stroke="#ff0000"')
206
+ const resolved = symbolRegistry.resolveActive(defWithSelected, {}, { id: STYLE_ID, selectedColor: COLOR_OVERRIDE })
207
+ expect(resolved).toContain(FILL_OVERRIDE)
200
208
  })
201
209
 
202
210
  it('still resolves other tokens correctly', () => {
203
- const resolved = symbolRegistry.resolveSelected(symbolDef, { backgroundColor: '#d4351c' }, mapStyle)
211
+ const resolved = symbolRegistry.resolveActive(symbolDef, { backgroundColor: '#d4351c' }, mapStyle)
204
212
  expect(resolved).toContain('fill="#d4351c"')
205
213
  })
206
214
  })
207
215
 
216
+ describe('symbolRegistry — resolveSelected (committed selection state)', () => {
217
+ const symbolDef = {
218
+ id: 'test-selected',
219
+ svg: '<path fill="{{selectedColor}}"/>'
220
+ }
221
+
222
+ it('returns empty string for null symbolDef', () => {
223
+ expect(symbolRegistry.resolveSelected(null, {}, mapStyle)).toBe('')
224
+ })
225
+
226
+ it('handles null styleColors — uses cascade defaults', () => {
227
+ const resolved = symbolRegistry.resolveSelected(symbolDef, null, mapStyle)
228
+ expect(typeof resolved).toBe('string')
229
+ expect(resolved.length).toBeGreaterThan(0)
230
+ })
231
+ })
232
+
208
233
  describe('symbolRegistry — graphic token', () => {
209
234
  const graphicDef = {
210
235
  id: 'test-graphic',
@@ -257,6 +282,91 @@ describe('symbolRegistry — graphic token', () => {
257
282
  const pin = symbolRegistry.get('pin')
258
283
  const resolved = symbolRegistry.resolve(pin, {}, mapStyle)
259
284
  expect(resolved).toContain(`d="${pin.graphic}"`)
260
- expect(resolved).toContain('translate(19, 16) scale(0.8) translate(-8, -8)')
285
+ expect(resolved).toContain('translate(22, 19) scale(0.8) translate(-8, -8)')
286
+ })
287
+ })
288
+
289
+ // ─── getSymbolDef ─────────────────────────────────────────────────────────────
290
+
291
+ describe('getSymbolDef', () => {
292
+ it('returns undefined when dataset has no symbol', () => {
293
+ expect(symbolRegistry.getSymbolDef({})).toBeUndefined()
294
+ })
295
+
296
+ it('looks up string symbol id in the registry', () => {
297
+ expect(symbolRegistry.getSymbolDef({ symbol: 'pin' })).toBe(pin)
298
+ })
299
+
300
+ it('returns undefined for an unregistered string symbol', () => {
301
+ expect(symbolRegistry.getSymbolDef({ symbol: 'missing' })).toBeUndefined()
302
+ })
303
+
304
+ it('returns inline def from symbolSvgContent with svg key', () => {
305
+ const dataset = { symbolSvgContent: '<circle/>', symbolViewBox: '0 0 10 10' }
306
+ const result = symbolRegistry.getSymbolDef(dataset)
307
+ expect(result.svg).toBe('<circle/>')
308
+ })
309
+
310
+ it('symbolSvgContent takes precedence over symbol id', () => {
311
+ const result = symbolRegistry.getSymbolDef({ symbol: 'pin', symbolSvgContent: '<circle/>' })
312
+ expect(result.svg).toBe('<circle/>')
313
+ })
314
+ })
315
+
316
+ // ─── getSymbolImageId ─────────────────────────────────────────────────────────
317
+
318
+ describe('getSymbolImageId', () => {
319
+ it('returns null when dataset has no symbol', () => {
320
+ expect(symbolRegistry.getSymbolImageId({}, mapStyle)).toBeNull()
321
+ })
322
+
323
+ it('returns null for an unregistered symbol id', () => {
324
+ expect(symbolRegistry.getSymbolImageId({ symbol: 'does-not-exist' }, mapStyle)).toBeNull()
325
+ })
326
+
327
+ it('returns a string prefixed symbol- for normal state', () => {
328
+ const id = symbolRegistry.getSymbolImageId({ symbol: 'pin' }, mapStyle)
329
+ expect(typeof id).toBe('string')
330
+ expect(id).toMatch(/^symbol-[a-z0-9]+-\d+(\.\d+)?x$/)
331
+ })
332
+
333
+ it('returns a string prefixed symbol-act- for active state', () => {
334
+ const id = symbolRegistry.getSymbolImageId({ symbol: 'pin' }, mapStyle, true)
335
+ expect(typeof id).toBe('string')
336
+ expect(id).toMatch(/^symbol-act-[a-z0-9]+-\d+(\.\d+)?x$/)
337
+ })
338
+
339
+ it('normal and active ids differ for the same dataset', () => {
340
+ const normalId = symbolRegistry.getSymbolImageId({ symbol: 'pin' }, mapStyle, false)
341
+ const activeId = symbolRegistry.getSymbolImageId({ symbol: 'pin' }, mapStyle, true)
342
+ expect(normalId).not.toBe(activeId)
343
+ })
344
+
345
+ it('same dataset and style always produces the same id', () => {
346
+ const id1 = symbolRegistry.getSymbolImageId({ symbol: 'pin' }, mapStyle)
347
+ const id2 = symbolRegistry.getSymbolImageId({ symbol: 'pin' }, mapStyle)
348
+ expect(id1).toBe(id2)
349
+ })
350
+
351
+ it('different symbols produce different ids', () => {
352
+ const pinId = symbolRegistry.getSymbolImageId({ symbol: 'pin' }, mapStyle)
353
+ const circleId = symbolRegistry.getSymbolImageId({ symbol: 'circle' }, mapStyle)
354
+ expect(pinId).not.toBe(circleId)
355
+ })
356
+
357
+ it('different backgrounds produce different ids', () => {
358
+ const redId = symbolRegistry.getSymbolImageId({ symbol: 'pin', symbolBackgroundColor: '#ff0000' }, mapStyle)
359
+ const blueId = symbolRegistry.getSymbolImageId({ symbol: 'pin', symbolBackgroundColor: '#0000ff' }, mapStyle)
360
+ expect(redId).not.toBe(blueId)
361
+ })
362
+
363
+ it('resolves inline symbolSvgContent', () => {
364
+ const dataset = {
365
+ symbolSvgContent: '<circle cx="19" cy="19" r="12" fill="{{backgroundColor}}"/>',
366
+ symbolViewBox: '0 0 38 38',
367
+ symbolAnchor: [0.5, 0.5]
368
+ }
369
+ const id = symbolRegistry.getSymbolImageId(dataset, mapStyle)
370
+ expect(id).toMatch(/^symbol-[a-z0-9]+-\d+(\.\d+)?x$/)
261
371
  })
262
372
  })
package/src/types.js CHANGED
@@ -91,6 +91,41 @@
91
91
  * Core services (announce, reverseGeocode, closeApp, etc.).
92
92
  */
93
93
 
94
+ /**
95
+ * Defines an item in a button's popup menu.
96
+ *
97
+ * @typedef {Object} MenuItemDefinition
98
+ *
99
+ * @property {string} id
100
+ * Unique item identifier. Used to control state via toggleButtonState().
101
+ *
102
+ * @property {string} label
103
+ * Display text for the item.
104
+ *
105
+ * @property {string} [iconId]
106
+ * Icon identifier from the icon registry.
107
+ *
108
+ * @property {string} [iconSvgContent]
109
+ * Raw SVG content for the item icon. The outer SVG tag should be excluded.
110
+ *
111
+ * @property {boolean} [isPressed]
112
+ * Initial checked state. When set, the item renders as menuitemcheckbox.
113
+ *
114
+ * @property {boolean} [keepFocus=false]
115
+ * When true, focus returns to the menu's trigger button after selection instead of
116
+ * moving to the panel (if panelId is set) or the map viewport.
117
+ *
118
+ * @property {(e: MouseEvent) => void} [onClick]
119
+ * Click handler. Receives the native event. Not called when panelId is set.
120
+ *
121
+ * @property {string} [panelId]
122
+ * Associated panel identifier. When set, selecting the item opens the panel and moves
123
+ * focus to it. onClick is not called.
124
+ *
125
+ * @property {(context: PluginContext) => boolean} [pressedWhen]
126
+ * Reactive callback to determine if the item should appear checked. Plugin buttons only.
127
+ */
128
+
94
129
  /**
95
130
  * Defines a button that can be rendered in the UI at various breakpoints.
96
131
  *
@@ -133,12 +168,21 @@
133
168
  * @property {string | (() => string)} label
134
169
  * Accesible label. Text or a function returning the text. Used for the label or tooltip if 'showLabel' is false.
135
170
  *
171
+ * @property {MenuItemDefinition[]} [menuItems]
172
+ * Items for the button's popup menu. When provided, the button acts as a menu trigger.
173
+ *
136
174
  * @property {ButtonBreakpointConfig} mobile
137
175
  * Mobile breakpoint configuration.
138
176
  *
139
177
  * @property {(event: MouseEvent, context: PluginContext) => void} [onClick]
140
178
  * Click handler for the button.
141
179
  *
180
+ * @property {boolean} [keepFocus=false]
181
+ * When true, focus remains on this button after activation instead of moving to the panel or viewport.
182
+ * Use for repeated incremental actions (e.g. zoom) where the user may activate the button
183
+ * multiple times. Toggle buttons (isPressed/pressedWhen) always keep focus regardless of this flag.
184
+ * When combined with panelId, the panel opens but focus stays on the button.
185
+ *
142
186
  * @property {string} [panelId]
143
187
  * Associated panel identifier to toggle open when clicked.
144
188
  *
@@ -221,6 +265,10 @@
221
265
  * @property {(point: { x: number, y: number }) => any[]} getFeaturesAtPoint
222
266
  * Query rendered features at a screen pixel position (x from left edge, y from top edge of viewport).
223
267
  *
268
+ * @property {(layerIds: string[]) => any[]} getVisibleFeatures
269
+ * Return all GeoJSON features currently visible in the viewport on the given layers,
270
+ * respecting layer visibility and any active filters. Returns [] if unsupported.
271
+ *
224
272
  * @property {() => string} getAreaDimensions
225
273
  * Get the dimensions of the visible map area as a formatted string (e.g., '400m by 750m').
226
274
  *
@@ -236,7 +284,7 @@
236
284
  * @property {(point: { x: number, y: number }) => [number, number]} screenToMap
237
285
  * Convert screen pixel position (x from left edge, y from top edge of viewport) to map coordinates ([lng, lat] or [easting, northing] depending on the crs of the map provider).
238
286
  *
239
- * @property {(selectedFeatures: any[], stylesMap: any) => any} [updateHighlightedFeatures]
287
+ * @property {(selectedFeatures: any[], activeFeatures: any[], stylesMap: any) => any} [updateHighlightedFeatures]
240
288
  * @experimental Update highlighted features on the map.
241
289
  *
242
290
  * @property {(direction: string) => any} [highlightNextLabel]
@@ -329,7 +377,7 @@
329
377
  * Alt text for logo.
330
378
  *
331
379
  * @property {'light' | 'dark'} [mapColorScheme]
332
- * Map colour scheme. Sets the default values of `haloColor`, `selectedColor`, and `foregroundColor`
380
+ * Map colour scheme. Sets the default values of `haloColor`, `selectedColor`, `activeColor`, and `foregroundColor`
333
381
  * when not explicitly provided, and signals to map overlay components which tonal range to use.
334
382
  * `'light'` (default): dark overlays on a light basemap. `'dark'`: light overlays on a dark or aerial basemap.
335
383
  *
@@ -342,10 +390,14 @@
342
390
  * Injected as the `--map-overlay-halo-color` CSS custom property.
343
391
  *
344
392
  * @property {string} [selectedColor]
345
- * Theme colour for selected state — used by map overlay components to indicate a selected feature.
393
+ * Theme colour for committed selection — used by map overlay components to indicate a selected feature.
346
394
  * Falls back to `#0b0c0c` (light) or `#ffffff` (dark).
347
395
  * Injected as the `--map-overlay-selected-color` CSS custom property.
348
396
  *
397
+ * @property {string} [activeColor]
398
+ * Theme colour for the active (keyboard cursor) state — the focus ring shown when navigating features
399
+ * with the keyboard. Falls back to `#ffdd00` for both light and dark schemes.
400
+ *
349
401
  * @property {string} [foregroundColor]
350
402
  * Foreground colour for elements rendered on top of the map (e.g. text or iconography in overlay components).
351
403
  * Falls back to `#0b0c0c` (light) or `#ffffff` (dark).
@@ -367,7 +419,7 @@
367
419
  * @property {string} [symbolSvgContent]
368
420
  * Default inner SVG path content. When set, overrides `symbol`.
369
421
  *
370
- * @property {string} [viewBox='0 0 38 38']
422
+ * @property {string} [viewBox='0 0 44 44']
371
423
  * Default SVG viewBox.
372
424
  *
373
425
  * @property {[number, number]} [anchor=[0.5, 0.5]]
@@ -379,15 +431,10 @@
379
431
  * @property {string | Record<string, string>} [foregroundColor='#ffffff']
380
432
  * Default foreground fill colour.
381
433
  *
382
- * @property {string} [haloWidth='1']
383
- * Default halo stroke width in SVG units.
384
- *
385
434
  * @property {string} [graphic]
386
435
  * Default SVG `d` attribute value for the foreground graphic path. Each built-in symbol sets
387
436
  * its own default (a small dot). Override to swap the graphic across all markers globally.
388
437
  *
389
- * @property {string} [selectedWidth='6']
390
- * Selection ring stroke width in SVG units. App-wide only — ignored at symbol registration and marker creation level.
391
438
  */
392
439
 
393
440
  /**
@@ -420,8 +467,8 @@
420
467
  * When set, `symbol` is ignored.
421
468
  *
422
469
  * @property {string} [viewBox]
423
- * SVG viewBox attribute for the symbol, e.g. `'0 0 38 38'`.
424
- * Defaults to the registered symbol's viewBox, or `'0 0 38 38'`.
470
+ * SVG viewBox attribute for the symbol, e.g. `'0 0 44 44'`.
471
+ * Defaults to the registered symbol's viewBox, or `'0 0 44 44'`.
425
472
  *
426
473
  * @property {[number, number]} [anchor]
427
474
  * Anchor point as a normalised [x, y] pair where [0, 0] is top-left and [1, 1] is bottom-right.
@@ -434,9 +481,6 @@
434
481
  * @property {string | Record<string, string>} [foregroundColor]
435
482
  * Foreground fill colour of the symbol (e.g. the inner dot on a pin).
436
483
  *
437
- * @property {string} [haloWidth]
438
- * Stroke width of the halo in SVG units. Defaults to `'1'`.
439
- *
440
484
  * @property {string} [graphic]
441
485
  * SVG `d` attribute value for the foreground graphic path. Replaces the foreground shape of the
442
486
  * symbol while keeping its background, halo and selection ring intact. Each built-in symbol
@@ -447,6 +491,14 @@
447
491
  * import { graphics } from './config/symbolConfig.js'
448
492
  * markers.add('id', coords, { symbol: 'pin', graphic: graphics.cross })
449
493
  *
494
+ * @property {string} [label]
495
+ * Plain-text label for this marker. Used as the accessible name on the marker SVG, and rendered
496
+ * as a visible label bubble when `showLabel` is `true` or when `symbol` is `null` (standalone label).
497
+ *
498
+ * @property {boolean} [showLabel=false]
499
+ * Whether to render a visible label bubble above the marker symbol. Defaults to `false`.
500
+ * Set `symbol` to `null` instead to create a standalone label with no symbol.
501
+ *
450
502
  */
451
503
 
452
504
  /**
@@ -457,6 +509,11 @@
457
509
  * @property {PanelBreakpointConfig} desktop
458
510
  * Desktop breakpoint configuration.
459
511
  *
512
+ * @property {boolean} [focus=true]
513
+ * Whether to move focus to the panel when it opens. Set to false to prevent the panel from
514
+ * receiving focus — useful for panels present on page load or panels that should not interrupt
515
+ * the user's current flow. Modal panels always receive focus regardless of this setting.
516
+ *
460
517
  * @property {string} [html]
461
518
  * HTML content.
462
519
  *
@@ -4,11 +4,11 @@ export function constrainKeyboardFocus (containerEl, e) {
4
4
  }
5
5
 
6
6
  const selectors = [
7
- 'a[href]:not([disabled])',
8
- 'button:not([disabled])',
9
- 'textarea:not([disabled])',
10
- 'input:not([disabled])',
11
- 'select:not([disabled])',
7
+ 'a[href]:not([disabled]):not([tabindex="-1"])',
8
+ 'button:not([disabled]):not([tabindex="-1"])',
9
+ 'textarea:not([disabled]):not([tabindex="-1"])',
10
+ 'input:not([disabled]):not([tabindex="-1"])',
11
+ 'select:not([disabled]):not([tabindex="-1"])',
12
12
  '*[tabindex="0"]:not([disabled])'
13
13
  ]
14
14
  let focusableEls = Array.from(containerEl.querySelectorAll(selectors.join(',')))
@@ -1,5 +1,6 @@
1
1
  let lastInterfaceType = window.matchMedia('(pointer: coarse)').matches ? 'touch' : 'unknown'
2
2
  const interfaceTypeListeners = new Set()
3
+ const interfaceTypeImmediateListeners = new Set()
3
4
 
4
5
  // -----------------------------------------------------------------------------
5
6
  // Internal (not exported)
@@ -20,9 +21,8 @@ function normalizePointerType (pointerType) {
20
21
  function notifyListeners (newType) {
21
22
  if (lastInterfaceType !== newType) {
22
23
  lastInterfaceType = newType
23
- interfaceTypeListeners.forEach((listener) => {
24
- listener(newType)
25
- })
24
+ interfaceTypeImmediateListeners.forEach(listener => listener(newType))
25
+ interfaceTypeListeners.forEach(listener => listener(newType))
26
26
  }
27
27
  }
28
28
 
@@ -44,7 +44,18 @@ function createInterfaceDetector () {
44
44
 
45
45
  const handlePointer = event => {
46
46
  const type = normalizePointerType(event.pointerType)
47
- setTimeout(() => notifyListeners(type), REACT_CLICK_DELAY)
47
+ if (type === lastInterfaceType) {
48
+ return
49
+ }
50
+ // Update synchronously so getInterfaceType() returns the new value immediately —
51
+ // this prevents focusin handlers from seeing the stale 'keyboard' type during a
52
+ // pointer-triggered focus move. Listeners (React state) are still notified async
53
+ // to avoid layout thrashing during the click event.
54
+ lastInterfaceType = type
55
+ interfaceTypeImmediateListeners.forEach(listener => listener(type))
56
+ setTimeout(() => {
57
+ interfaceTypeListeners.forEach(listener => listener(type))
58
+ }, REACT_CLICK_DELAY)
48
59
  }
49
60
 
50
61
  const handleKeyDown = e => {
@@ -81,8 +92,19 @@ function subscribeToInterfaceChanges (onInterfaceTypeChange) {
81
92
  }
82
93
  }
83
94
 
95
+ // Fires synchronously within the same event cycle — use for direct DOM updates
96
+ // that need to be in sync with the pointer event (no 150ms React delay).
97
+ function subscribeToInterfaceChangesImmediate (onInterfaceTypeChange) {
98
+ interfaceTypeImmediateListeners.add(onInterfaceTypeChange)
99
+
100
+ return () => {
101
+ interfaceTypeImmediateListeners.delete(onInterfaceTypeChange)
102
+ }
103
+ }
104
+
84
105
  export {
85
106
  createInterfaceDetector,
86
107
  getInterfaceType,
87
- subscribeToInterfaceChanges
108
+ subscribeToInterfaceChanges,
109
+ subscribeToInterfaceChangesImmediate
88
110
  }
@@ -54,7 +54,7 @@ const triggerDomEvent = (event, payload) => {
54
54
 
55
55
  // --- TEST SUITE ---
56
56
  describe('Interface Detector Utility Module', () => {
57
- let createInterfaceDetector, getInterfaceType, subscribeToInterfaceChanges
57
+ let createInterfaceDetector, getInterfaceType, subscribeToInterfaceChanges, subscribeToInterfaceChangesImmediate
58
58
  let cleanup
59
59
 
60
60
  beforeEach(async () => {
@@ -72,6 +72,7 @@ describe('Interface Detector Utility Module', () => {
72
72
  createInterfaceDetector = importedModule.createInterfaceDetector
73
73
  getInterfaceType = importedModule.getInterfaceType
74
74
  subscribeToInterfaceChanges = importedModule.subscribeToInterfaceChanges
75
+ subscribeToInterfaceChangesImmediate = importedModule.subscribeToInterfaceChangesImmediate
75
76
 
76
77
  Object.keys(eventListeners).forEach(key => delete eventListeners[key])
77
78
  Object.keys(mediaListeners).forEach(key => delete mediaListeners[key])
@@ -99,18 +100,21 @@ describe('Interface Detector Utility Module', () => {
99
100
 
100
101
  // --- Path 1a: pointerdown 'touch' ---
101
102
  triggerDomEvent('pointerdown', { pointerType: 'touch' })
103
+ expect(getInterfaceType()).toBe('touch') // synchronous update
104
+ expect(handler).not.toHaveBeenCalled() // listener still pending
102
105
  jest.advanceTimersByTime(150)
103
- expect(getInterfaceType()).toBe('touch')
104
106
  expect(handler).toHaveBeenCalledWith('touch')
105
107
  handler.mockClear()
106
108
 
107
109
  // --- Path 1b: pointerdown 'pen' ---
108
110
  triggerDomEvent('pointerdown', { pointerType: 'pen' })
109
111
  jest.advanceTimersByTime(150)
110
- expect(handler).not.toHaveBeenCalled()
112
+ expect(handler).not.toHaveBeenCalled() // 'pen' normalises to 'touch' — no change
111
113
 
112
114
  // --- Path 1c: pointerdown 'mouse' ---
113
115
  triggerDomEvent('pointerdown', { pointerType: 'mouse' })
116
+ expect(getInterfaceType()).toBe('mouse') // synchronous update
117
+ expect(handler).not.toHaveBeenCalled() // listener still pending
114
118
  jest.advanceTimersByTime(150)
115
119
  expect(getInterfaceType()).toBe('mouse')
116
120
  expect(handler).toHaveBeenCalledWith('mouse')
@@ -129,6 +133,10 @@ describe('Interface Detector Utility Module', () => {
129
133
  expect(handler).toHaveBeenCalledWith('keyboard')
130
134
  handler.mockClear()
131
135
 
136
+ // Tab again when already keyboard — notifyListeners no-op branch
137
+ triggerDomEvent('keydown', { key: 'Tab' })
138
+ expect(handler).not.toHaveBeenCalled()
139
+
132
140
  // keydown with non-Tab
133
141
  triggerDomEvent('keydown', { key: 'a' })
134
142
  expect(handler).not.toHaveBeenCalled()
@@ -152,6 +160,24 @@ describe('Interface Detector Utility Module', () => {
152
160
  expect(handler).not.toHaveBeenCalled()
153
161
  })
154
162
 
163
+ it('subscribeToInterfaceChangesImmediate fires synchronously and unsubscribes', () => {
164
+ const handler = jest.fn()
165
+ const unsubscribe = subscribeToInterfaceChangesImmediate(handler)
166
+ cleanup = createInterfaceDetector()
167
+
168
+ triggerDomEvent('pointerdown', { pointerType: 'touch' })
169
+ expect(handler).toHaveBeenCalledWith('touch') // fires before timer advance
170
+
171
+ // Tab keydown goes through notifyListeners — exercises immediate forEach callback
172
+ triggerDomEvent('keydown', { key: 'Tab' })
173
+ expect(handler).toHaveBeenCalledWith('keyboard')
174
+ expect(handler).toHaveBeenCalledTimes(2)
175
+
176
+ unsubscribe()
177
+ triggerDomEvent('pointerdown', { pointerType: 'mouse' })
178
+ expect(handler).toHaveBeenCalledTimes(2) // no longer called after unsubscribe
179
+ })
180
+
155
181
  it('should return "touch" when matchMedia initially matches coarse pointer', async () => {
156
182
  jest.resetModules()
157
183